From b18c2eccbbb49e9cf405c0dfabd19a268c1ffd33 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Sat, 21 Feb 2026 03:07:51 +0000 Subject: [PATCH] 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` 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) --- knip.json | 41 +- orchestrator/package.json | 3 +- .../client/components/OnboardingGate.test.tsx | 4 +- .../src/client/components/OnboardingGate.tsx | 10 +- .../src/client/pages/OrchestratorPage.tsx | 462 +------ .../src/client/pages/SettingsPage.test.tsx | 49 +- .../src/client/pages/SettingsPage.tsx | 116 +- .../orchestrator/AutomaticRunTab.test.tsx | 116 +- .../pages/orchestrator/AutomaticRunTab.tsx | 16 +- .../orchestrator/useKeyboardShortcuts.ts | 282 +++++ .../pages/orchestrator/usePipelineControls.ts | 196 +++ .../src/server/api/routes/onboarding.ts | 2 +- .../src/server/api/routes/settings.test.ts | 24 +- .../src/server/api/routes/settings.ts | 6 +- .../src/server/repositories/settings.ts | 49 +- .../src/server/services/envSettings.ts | 159 +-- .../services/ghostwriter-context.test.ts | 63 +- .../server/services/ghostwriter-context.ts | 29 +- .../src/server/services/llm-service.test.ts | 8 +- .../src/server/services/llm-service.ts | 14 - orchestrator/src/server/services/manualJob.ts | 3 +- .../ingestion/email-router.ts | 213 ++++ .../post-application/ingestion/gmail-api.ts | 417 ++++++ .../ingestion/gmail-sync.idempotency.test.ts | 2 +- .../ingestion/gmail-sync.test.ts | 3 +- .../post-application/ingestion/gmail-sync.ts | 663 +--------- .../src/server/services/projectSelection.ts | 3 +- .../src/server/services/scorer.test.ts | 108 +- orchestrator/src/server/services/scorer.ts | 17 +- .../services/settings-conversion.test.ts | 188 --- .../server/services/settings-conversion.ts | 273 ---- .../server/services/settings-update/index.ts | 7 +- .../services/settings-update/registry.ts | 336 +---- orchestrator/src/server/services/settings.ts | 365 ++---- orchestrator/src/server/services/summary.ts | 3 +- shared/src/settings-registry.test.ts | 105 ++ shared/src/settings-registry.ts | 423 +++++++ shared/src/settings-schema.ts | 145 +-- shared/src/testing/factories.ts | 137 +- shared/src/types.ts | 1124 +---------------- shared/src/types/api.ts | 122 ++ shared/src/types/chat.ts | 95 ++ shared/src/types/jobs.ts | 322 +++++ shared/src/types/pipeline.ts | 124 ++ shared/src/types/post-application.ts | 208 +++ shared/src/types/settings.ts | 164 +++ shared/src/types/visa-sponsors.ts | 28 + 47 files changed, 3437 insertions(+), 3810 deletions(-) create mode 100644 orchestrator/src/client/pages/orchestrator/useKeyboardShortcuts.ts create mode 100644 orchestrator/src/client/pages/orchestrator/usePipelineControls.ts delete mode 100644 orchestrator/src/server/services/llm-service.ts create mode 100644 orchestrator/src/server/services/post-application/ingestion/email-router.ts create mode 100644 orchestrator/src/server/services/post-application/ingestion/gmail-api.ts delete mode 100644 orchestrator/src/server/services/settings-conversion.test.ts delete mode 100644 orchestrator/src/server/services/settings-conversion.ts create mode 100644 shared/src/settings-registry.test.ts create mode 100644 shared/src/settings-registry.ts create mode 100644 shared/src/types/api.ts create mode 100644 shared/src/types/chat.ts create mode 100644 shared/src/types/jobs.ts create mode 100644 shared/src/types/pipeline.ts create mode 100644 shared/src/types/post-application.ts create mode 100644 shared/src/types/settings.ts create mode 100644 shared/src/types/visa-sponsors.ts diff --git a/knip.json b/knip.json index 6295ecb..e90df13 100644 --- a/knip.json +++ b/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"] + } } } diff --git a/orchestrator/package.json b/orchestrator/package.json index 5d28302..6c62a79 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -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", diff --git a/orchestrator/src/client/components/OnboardingGate.test.tsx b/orchestrator/src/client/components/OnboardingGate.test.tsx index 9d21683..ba124bb 100644 --- a/orchestrator/src/client/components/OnboardingGate.test.tsx +++ b/orchestrator/src/client/components/OnboardingGate.test.tsx @@ -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({ diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 8967199..796aa9f 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -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} /> )} diff --git a/orchestrator/src/client/pages/OrchestratorPage.tsx b/orchestrator/src/client/pages/OrchestratorPage.tsx index b349f52..8e9f18a 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.tsx @@ -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("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) => { diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 5e27064..13f8aac 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -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 () => { diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index a91ddd6..bbc0bec 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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 = () => { />
- + { { { { { { { { = ({ 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 = ({ country: rememberedCountry || DEFAULT_VALUES.country, cityLocations: rememberedLocations, cityLocationDraft: "", - searchTerms: settings?.searchTerms ?? DEFAULT_VALUES.searchTerms, + searchTerms: settings?.searchTerms?.value ?? DEFAULT_VALUES.searchTerms, searchTermDraft: "", }); setAdvancedOpen(false); diff --git a/orchestrator/src/client/pages/orchestrator/useKeyboardShortcuts.ts b/orchestrator/src/client/pages/orchestrator/useKeyboardShortcuts.ts new file mode 100644 index 0000000..f450f40 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/useKeyboardShortcuts.ts @@ -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; + 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; + loadJobs: () => Promise; +}; + +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 }, + ); +} diff --git a/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts b/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts new file mode 100644 index 0000000..17eaec1 --- /dev/null +++ b/orchestrator/src/client/pages/orchestrator/usePipelineControls.ts @@ -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; + 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; + handleSaveAndRunAutomatic: (values: AutomaticRunValues) => Promise; + handleManualImported: (importedJobId: string) => Promise; + refreshSettings: () => Promise; +}; + +export function usePipelineControls( + args: UsePipelineControlsArgs, +): UsePipelineControlsResult { + const { + isPipelineRunning, + setIsPipelineRunning, + pipelineTerminalEvent, + pipelineSources, + loadJobs, + navigateWithContext, + } = args; + + const [isRunModeModalOpen, setIsRunModeModalOpen] = useState(false); + const [runMode, setRunMode] = useState("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, + }; +} diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts index 00100cd..de2cd4a 100644 --- a/orchestrator/src/server/api/routes/onboarding.ts +++ b/orchestrator/src/server/api/routes/onboarding.ts @@ -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, diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index 653d277..b7723c9 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -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); }); }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 1a7114d..49c7ddb 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -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 }); diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 7332e06..b1cfd51 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -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 { const [row] = await db.select().from(settings).where(eq(settings.key, key)); diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts index 273e58d..53a1756 100644 --- a/orchestrator/src/server/services/envSettings.ts +++ b/orchestrator/src/server/services/envSettings.ts @@ -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 = { ...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 { const safeGetSetting = async (key: SettingKey): Promise => { 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>, -): Promise> { +): Promise> { const activeOverrides = overrides || (await settingsRepo.getAllSettings()); - const readableValues: Record = {}; - const privateValues: Record = {}; + const values: Partial = {}; - 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; +} diff --git a/orchestrator/src/server/services/ghostwriter-context.test.ts b/orchestrator/src/server/services/ghostwriter-context.test.ts index b9daa01..eb75d42 100644 --- a/orchestrator/src/server/services/ghostwriter-context.test.ts +++ b/orchestrator/src/server/services/ghostwriter-context.test.ts @@ -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 = { - 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", diff --git a/orchestrator/src/server/services/ghostwriter-context.ts b/orchestrator/src/server/services/ghostwriter-context.ts index 31df771..c099ca8 100644 --- a/orchestrator/src/server/services/ghostwriter-context.ts +++ b/orchestrator/src/server/services/ghostwriter-context.ts @@ -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 { - 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, }; } diff --git a/orchestrator/src/server/services/llm-service.test.ts b/orchestrator/src/server/services/llm-service.test.ts index 39e6d2c..7cc378e 100644 --- a/orchestrator/src/server/services/llm-service.test.ts +++ b/orchestrator/src/server/services/llm-service.test.ts @@ -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; diff --git a/orchestrator/src/server/services/llm-service.ts b/orchestrator/src/server/services/llm-service.ts deleted file mode 100644 index 8b5f6a4..0000000 --- a/orchestrator/src/server/services/llm-service.ts +++ /dev/null @@ -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"; diff --git a/orchestrator/src/server/services/manualJob.ts b/orchestrator/src/server/services/manualJob.ts index 67c1136..db6b9a5 100644 --- a/orchestrator/src/server/services/manualJob.ts +++ b/orchestrator/src/server/services/manualJob.ts @@ -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; diff --git a/orchestrator/src/server/services/post-application/ingestion/email-router.ts b/orchestrator/src/server/services/post-application/ingestion/email-router.ts new file mode 100644 index 0000000..b74679d --- /dev/null +++ b/orchestrator/src/server/services/post-application/ingestion/email-router.ts @@ -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 | 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 { + 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 | 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(), + }; +} diff --git a/orchestrator/src/server/services/post-application/ingestion/gmail-api.ts b/orchestrator/src/server/services/post-application/ingestion/gmail-api.ts new file mode 100644 index 0000000..6286e19 --- /dev/null +++ b/orchestrator/src/server/services/post-application/ingestion/gmail-api.ts @@ -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 { + 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 { + 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(token: string, url: string): Promise { + 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 { + 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 { + 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 { + 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, +): 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(); + 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): void => { + const mimeType = String(part.mimeType ?? "").toLowerCase(); + + if (mimeType === "multipart/alternative") { + const children = (part.parts ?? []) as Array< + NonNullable + >; + 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); + } + }; + + 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; +} diff --git a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.idempotency.test.ts b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.idempotency.test.ts index d81b820..c382d90 100644 --- a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.idempotency.test.ts +++ b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.idempotency.test.ts @@ -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(); diff --git a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.test.ts b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.test.ts index d2729e2..2634d75 100644 --- a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.test.ts +++ b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.test.ts @@ -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; diff --git a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.ts b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.ts index 7d9e716..704ca93 100644 --- a/orchestrator/src/server/services/post-application/ingestion/gmail-sync.ts +++ b/orchestrator/src/server/services/post-application/ingestion/gmail-sync.ts @@ -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 | 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 { - 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(token: string, url: string): Promise { - 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 { - 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 { - 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, -): 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(); - 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): void => { - const mimeType = String(part.mimeType ?? "").toLowerCase(); - - if (mimeType === "multipart/alternative") { - const children = (part.parts ?? []) as Array< - NonNullable - >; - 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); - } - }; - - 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 { - 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 | 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 { - 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 { - 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 { diff --git a/orchestrator/src/server/services/projectSelection.ts b/orchestrator/src/server/services/projectSelection.ts index 2926a7f..0e4d21a 100644 --- a/orchestrator/src/server/services/projectSelection.ts +++ b/orchestrator/src/server/services/projectSelection.ts @@ -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 */ diff --git a/orchestrator/src/server/services/scorer.test.ts b/orchestrator/src/server/services/scorer.test.ts index 0f0168b..6413fa2 100644 --- a/orchestrator/src/server/services/scorer.test.ts +++ b/orchestrator/src/server/services/scorer.test.ts @@ -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, diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index 3550d41..b9baa21 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -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 { diff --git a/orchestrator/src/server/services/settings-conversion.test.ts b/orchestrator/src/server/services/settings-conversion.test.ts deleted file mode 100644 index 12ffb3f..0000000 --- a/orchestrator/src/server/services/settings-conversion.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/orchestrator/src/server/services/settings-conversion.ts b/orchestrator/src/server/services/settings-conversion.ts deleted file mode 100644 index 2c25f4e..0000000 --- a/orchestrator/src/server/services/settings-conversion.ts +++ /dev/null @@ -1,273 +0,0 @@ -type SettingMetadata = { - 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(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( - 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( - key: K, - value: SettingsConversionInputMap[K], -): string | null { - const metadata = settingsConversionMetadata[key]; - return metadata.serialize(value); -} diff --git a/orchestrator/src/server/services/settings-update/index.ts b/orchestrator/src/server/services/settings-update/index.ts index d42f8e6..cb10e87 100644 --- a/orchestrator/src/server/services/settings-update/index.ts +++ b/orchestrator/src/server/services/settings-update/index.ts @@ -7,9 +7,4 @@ export type { SettingsUpdateResult, SettingUpdateHandler, } from "./registry"; -export { - settingsUpdateRegistry, - toJsonOrNull, - toNormalizedStringOrNull, - toNumberStringOrNull, -} from "./registry"; +export { settingsUpdateRegistry } from "./registry"; diff --git a/orchestrator/src/server/services/settings-update/registry.ts b/orchestrator/src/server/services/settings-update/registry.ts index b4c844f..108e8cd 100644 --- a/orchestrator/src/server/services/settings-update/registry.ts +++ b/orchestrator/src/server/services/settings-update/registry.ts @@ -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(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[0], + settingKey: SettingKey, value: string | null, sideEffect?: () => void | Promise, ): SettingsUpdateAction { @@ -79,270 +60,63 @@ function persistAction( }; } -function singleAction( - fn: SettingUpdateHandler, -): SettingUpdateHandler { - 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; -}> = { - 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[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)], - }), - ), -}; + }; +} diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index fd2240e..fa214e2 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -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 { 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 = { ...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; } diff --git a/orchestrator/src/server/services/summary.ts b/orchestrator/src/server/services/summary.ts index ee510e6..3ba510d 100644 --- a/orchestrator/src/server/services/summary.ts +++ b/orchestrator/src/server/services/summary.ts @@ -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; diff --git a/shared/src/settings-registry.test.ts b/shared/src/settings-registry.test.ts new file mode 100644 index 0000000..615cd60 --- /dev/null +++ b/shared/src/settings-registry.test.ts @@ -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(); + }); + }); +}); diff --git a/shared/src/settings-registry.ts b/shared/src/settings-registry.ts new file mode 100644 index 0000000..95fe849 --- /dev/null +++ b/shared/src/settings-registry.ts @@ -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; diff --git a/shared/src/settings-schema.ts b/shared/src/settings-schema.ts index fbdfc6b..c3f39c0 100644 --- a/shared/src/settings-schema.ts +++ b/shared/src/settings-schema.ts @@ -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; + } + ? K extends "enableBasicAuth" + ? z.ZodOptional> + : z.ZodOptional>> + : 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; export type ResumeProjectsSettingsInput = z.infer; diff --git a/shared/src/testing/factories.ts b/shared/src/testing/factories.ts index fe16f18..a4e4d44 100644 --- a/shared/src/testing/factories.ts +++ b/shared/src/testing/factories.ts @@ -125,76 +125,57 @@ export const createResumeProjectCatalogItem = ( export const createAppSettings = ( overrides: Partial = {}, ): 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, }); diff --git a/shared/src/types.ts b/shared/src/types.ts index d9db13b..db6ee45 100644 --- a/shared/src/types.ts +++ b/shared/src/types.ts @@ -1,1118 +1,14 @@ /** * Shared types for the job-ops orchestrator. + * + * Types are organized by domain in the `./types/` subdirectory. + * This file re-exports everything for backward compatibility. */ -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 = { - 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; -} - -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; -} - -// API Response types -export interface ApiMeta { - requestId: string; - simulated?: boolean; - blockedReason?: string; -} - -export interface ApiErrorPayload { - code: string; - message: string; - details?: unknown; -} - -export type ApiResponse = - | { - 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; -} - -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 | 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 | null; - relevanceLlmScore: number | null; - relevanceDecision: PostApplicationRelevanceDecision; - matchedJobId: string | null; - matchConfidence: number | null; - stageTarget: PostApplicationRouterStageTarget | null; - messageType: PostApplicationMessageType; - stageEventPayload: Record | 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; -} - -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[]; -} - -export interface JobsListResponse { - jobs: TJob[]; - total: number; - byStatus: Record; - 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 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; - }; - -// Visa Sponsors types -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; -} - -export interface PipelineStatusResponse { - isRunning: boolean; - lastRun: PipelineRun | null; - nextScheduledRun: string | null; -} - -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 interface AppSettings { - model: string; - defaultModel: string; - overrideModel: string | null; - // Specific model overrides - modelScorer: string; // resolved - overrideModelScorer: string | null; - modelTailoring: string; // resolved - overrideModelTailoring: string | null; - modelProjectSelection: string; // resolved - overrideModelProjectSelection: string | null; - - llmProvider: string; - defaultLlmProvider: string; - overrideLlmProvider: string | null; - llmBaseUrl: string; - defaultLlmBaseUrl: string; - overrideLlmBaseUrl: string | null; - - pipelineWebhookUrl: string; - defaultPipelineWebhookUrl: string; - overridePipelineWebhookUrl: string | null; - jobCompleteWebhookUrl: string; - defaultJobCompleteWebhookUrl: string; - overrideJobCompleteWebhookUrl: string | null; - profileProjects: ResumeProjectCatalogItem[]; - resumeProjects: ResumeProjectsSettings; - defaultResumeProjects: ResumeProjectsSettings; - overrideResumeProjects: ResumeProjectsSettings | null; - rxresumeBaseResumeId: string | null; - ukvisajobsMaxJobs: number; - defaultUkvisajobsMaxJobs: number; - overrideUkvisajobsMaxJobs: number | null; - adzunaMaxJobsPerTerm: number; - defaultAdzunaMaxJobsPerTerm: number; - overrideAdzunaMaxJobsPerTerm: number | null; - gradcrackerMaxJobsPerTerm: number; - defaultGradcrackerMaxJobsPerTerm: number; - overrideGradcrackerMaxJobsPerTerm: number | null; - searchTerms: string[]; - defaultSearchTerms: string[]; - overrideSearchTerms: string[] | null; - searchCities: string; - defaultSearchCities: string; - overrideSearchCities: string | null; - jobspyResultsWanted: number; - defaultJobspyResultsWanted: number; - overrideJobspyResultsWanted: number | null; - jobspyCountryIndeed: string; - defaultJobspyCountryIndeed: string; - overrideJobspyCountryIndeed: string | null; - showSponsorInfo: boolean; - defaultShowSponsorInfo: boolean; - overrideShowSponsorInfo: boolean | null; - chatStyleTone: string; - defaultChatStyleTone: string; - overrideChatStyleTone: string | null; - chatStyleFormality: string; - defaultChatStyleFormality: string; - overrideChatStyleFormality: string | null; - chatStyleConstraints: string; - defaultChatStyleConstraints: string; - overrideChatStyleConstraints: string | null; - chatStyleDoNotUse: string; - defaultChatStyleDoNotUse: string; - overrideChatStyleDoNotUse: string | null; - llmApiKeyHint: string | null; - rxresumeEmail: string | null; - rxresumePasswordHint: string | null; - basicAuthUser: string | null; - basicAuthPasswordHint: string | null; - ukvisajobsEmail: string | null; - ukvisajobsPasswordHint: string | null; - adzunaAppId: string | null; - adzunaAppKeyHint: string | null; - webhookSecretHint: string | null; - basicAuthActive: boolean; - // Backup settings - backupEnabled: boolean; - defaultBackupEnabled: boolean; - overrideBackupEnabled: boolean | null; - backupHour: number; - defaultBackupHour: number; - overrideBackupHour: number | null; - backupMaxCount: number; - defaultBackupMaxCount: number; - overrideBackupMaxCount: number | null; - // Scoring settings - penalizeMissingSalary: boolean; - defaultPenalizeMissingSalary: boolean; - overridePenalizeMissingSalary: boolean | null; - missingSalaryPenalty: number; - defaultMissingSalaryPenalty: number; - overrideMissingSalaryPenalty: number | null; - // Auto-skip settings - autoSkipScoreThreshold: number | null; - defaultAutoSkipScoreThreshold: number | null; - overrideAutoSkipScoreThreshold: number | null; -} - -export interface BackupInfo { - filename: string; - type: "auto" | "manual"; - size: number; - createdAt: string; -} +export * from "./types/api"; +export * from "./types/chat"; +export * from "./types/jobs"; +export * from "./types/pipeline"; +export * from "./types/post-application"; +export * from "./types/settings"; +export * from "./types/visa-sponsors"; diff --git a/shared/src/types/api.ts b/shared/src/types/api.ts new file mode 100644 index 0000000..2dfedd6 --- /dev/null +++ b/shared/src/types/api.ts @@ -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 = + | { + 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; +} diff --git a/shared/src/types/chat.ts b/shared/src/types/chat.ts new file mode 100644 index 0000000..350cab8 --- /dev/null +++ b/shared/src/types/chat.ts @@ -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; + }; diff --git a/shared/src/types/jobs.ts b/shared/src/types/jobs.ts new file mode 100644 index 0000000..755c8c7 --- /dev/null +++ b/shared/src/types/jobs.ts @@ -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 = { + 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; +} diff --git a/shared/src/types/pipeline.ts b/shared/src/types/pipeline.ts new file mode 100644 index 0000000..e28b71d --- /dev/null +++ b/shared/src/types/pipeline.ts @@ -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 { + jobs: TJob[]; + total: number; + byStatus: Record; + 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; +} diff --git a/shared/src/types/post-application.ts b/shared/src/types/post-application.ts new file mode 100644 index 0000000..fc4a3fe --- /dev/null +++ b/shared/src/types/post-application.ts @@ -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 | 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 | null; + relevanceLlmScore: number | null; + relevanceDecision: PostApplicationRelevanceDecision; + matchedJobId: string | null; + matchConfidence: number | null; + stageTarget: PostApplicationRouterStageTarget | null; + messageType: PostApplicationMessageType; + stageEventPayload: Record | 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; +} + +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[]; +} diff --git a/shared/src/types/settings.ts b/shared/src/types/settings.ts new file mode 100644 index 0000000..e15c502 --- /dev/null +++ b/shared/src/types/settings.ts @@ -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 = { value: T; default: T; override: T | null }; +export type ModelResolved = { value: string; override: string | null }; + +export interface AppSettings { + // Typed settings (Resolved): + model: Resolved; + llmProvider: Resolved; + llmBaseUrl: Resolved; + pipelineWebhookUrl: Resolved; + jobCompleteWebhookUrl: Resolved; + resumeProjects: Resolved; + ukvisajobsMaxJobs: Resolved; + adzunaMaxJobsPerTerm: Resolved; + gradcrackerMaxJobsPerTerm: Resolved; + searchTerms: Resolved; + searchCities: Resolved; + jobspyResultsWanted: Resolved; + jobspyCountryIndeed: Resolved; + showSponsorInfo: Resolved; + chatStyleTone: Resolved; + chatStyleFormality: Resolved; + chatStyleConstraints: Resolved; + chatStyleDoNotUse: Resolved; + backupEnabled: Resolved; + backupHour: Resolved; + backupMaxCount: Resolved; + penalizeMissingSalary: Resolved; + missingSalaryPenalty: Resolved; + autoSkipScoreThreshold: Resolved; + + // 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[]; +} diff --git a/shared/src/types/visa-sponsors.ts b/shared/src/types/visa-sponsors.ts new file mode 100644 index 0000000..5fb5300 --- /dev/null +++ b/shared/src/types/visa-sponsors.ts @@ -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; +}