diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index f60d8c6..5c8c427 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -198,6 +198,14 @@ export async function updateSettings(update: { jobspySites?: string[] | null jobspyLinkedinFetchDescription?: boolean | null showSponsorInfo?: boolean | null + openrouterApiKey?: string | null + rxresumeEmail?: string | null + rxresumePassword?: string | null + basicAuthUser?: string | null + basicAuthPassword?: string | null + ukvisajobsEmail?: string | null + ukvisajobsPassword?: string | null + webhookSecret?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index d488b22..ba334f3 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -95,6 +95,15 @@ const baseSettings: AppSettings = { showSponsorInfo: true, defaultShowSponsorInfo: true, overrideShowSponsorInfo: null, + openrouterApiKeyHint: null, + rxresumeEmail: "", + rxresumePasswordHint: null, + basicAuthUser: "", + basicAuthPasswordHint: null, + ukvisajobsEmail: "", + ukvisajobsPasswordHint: null, + webhookSecretHint: null, + basicAuthActive: false, } const renderPage = () => { @@ -186,4 +195,89 @@ describe("SettingsPage", () => { }) ) }) + + it("enables save button when model is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + 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(/override model/i) + fireEvent.change(modelInput, { target: { value: "new-model" } }) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when numeric setting is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i }) + fireEvent.click(visaTrigger) + const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i) + fireEvent.change(maxJobsInput, { target: { value: "100" } }) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when display setting is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const displayTrigger = await screen.findByRole("button", { name: /display settings/i }) + fireEvent.click(displayTrigger) + const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i) + fireEvent.click(sponsorCheckbox) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when basic auth toggle is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i }) + fireEvent.click(envTrigger) + const authCheckbox = screen.getByLabelText(/enable basic authentication/i) + fireEvent.click(authCheckbox) + expect(saveButton).toBeEnabled() + }) + + it("wipes basic auth credentials when toggle is disabled and saved", async () => { + // Initial state: Basic Auth is active + const activeSettings = { + ...baseSettings, + basicAuthActive: true, + basicAuthUser: "admin", + basicAuthPasswordHint: "pass", + } + vi.mocked(api.getSettings).mockResolvedValue(activeSettings) + vi.mocked(api.updateSettings).mockResolvedValue(baseSettings) + + renderPage() + + const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i }) + fireEvent.click(envTrigger) + + const authCheckbox = screen.getByLabelText(/enable basic authentication/i) + expect(authCheckbox).toBeChecked() + + // Disable it + fireEvent.click(authCheckbox) + expect(authCheckbox).not.toBeChecked() + + const saveButton = screen.getByRole("button", { name: /^save$/i }) + expect(saveButton).toBeEnabled() + fireEvent.click(saveButton) + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()) + expect(api.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + basicAuthUser: null, + basicAuthPassword: null, + }) + ) + }) }) diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 37b4c90..1df8f43 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -14,11 +14,11 @@ import { arraysEqual } from "@/lib/utils" import { resumeProjectsEqual } from "@client/pages/settings/utils" import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection" import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection" +import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection" import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection" -import { JobCompleteWebhookSection } from "@client/pages/settings/components/JobCompleteWebhookSection" import { JobspySection } from "@client/pages/settings/components/JobspySection" import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection" -import { PipelineWebhookSection } from "@client/pages/settings/components/PipelineWebhookSection" +import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection" import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection" import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection" import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection" @@ -41,6 +41,15 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { jobspySites: null, jobspyLinkedinFetchDescription: null, showSponsorInfo: null, + openrouterApiKey: "", + rxresumeEmail: "", + rxresumePassword: "", + basicAuthUser: "", + basicAuthPassword: "", + ukvisajobsEmail: "", + ukvisajobsPassword: "", + webhookSecret: "", + enableBasicAuth: false, } const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { @@ -61,6 +70,15 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { jobspySites: null, jobspyLinkedinFetchDescription: null, showSponsorInfo: null, + openrouterApiKey: null, + rxresumeEmail: null, + rxresumePassword: null, + basicAuthUser: null, + basicAuthPassword: null, + ukvisajobsEmail: null, + ukvisajobsPassword: null, + webhookSecret: null, + enableBasicAuth: undefined, } const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ @@ -81,6 +99,15 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ jobspySites: data.overrideJobspySites, jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription, showSponsorInfo: data.overrideShowSponsorInfo, + openrouterApiKey: "", + rxresumeEmail: data.rxresumeEmail ?? "", + rxresumePassword: "", + basicAuthUser: data.basicAuthUser ?? "", + basicAuthPassword: "", + ukvisajobsEmail: data.ukvisajobsEmail ?? "", + ukvisajobsPassword: "", + webhookSecret: "", + enableBasicAuth: data.basicAuthActive, }) const normalizeString = (value: string | null | undefined) => { @@ -88,6 +115,12 @@ const normalizeString = (value: string | null | undefined) => { return trimmed ? trimmed : null } +const normalizePrivateInput = (value: string | null | undefined) => { + const trimmed = value?.trim() + if (trimmed === "") return null + return trimmed || undefined +} + const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => { if (!left && !right) return true if (!left || !right) return false @@ -170,7 +203,23 @@ const getDerivedSettings = (settings: AppSettings | null) => { effective: settings?.showSponsorInfo ?? true, default: settings?.defaultShowSponsorInfo ?? true, }, + envSettings: { + readable: { + rxresumeEmail: settings?.rxresumeEmail ?? "", + ukvisajobsEmail: settings?.ukvisajobsEmail ?? "", + basicAuthUser: settings?.basicAuthUser ?? "", + }, + private: { + openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null, + rxresumePasswordHint: settings?.rxresumePasswordHint ?? null, + ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null, + basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null, + webhookSecretHint: settings?.webhookSecretHint ?? null, + }, + basicAuthActive: settings?.basicAuthActive ?? false, + }, defaultResumeProjects: settings?.defaultResumeProjects ?? null, + profileProjects, maxProjectsTotal: profileProjects.length, } @@ -188,7 +237,7 @@ export const SettingsPage: React.FC = () => { defaultValues: DEFAULT_FORM_VALUES, }) - const { handleSubmit, reset, watch, formState: { isDirty, errors, isValid } } = methods + const { handleSubmit, reset, setError, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods useEffect(() => { let isMounted = true @@ -224,6 +273,7 @@ export const SettingsPage: React.FC = () => { searchTerms, jobspy, display, + envSettings, defaultResumeProjects, profileProjects, maxProjectsTotal, @@ -236,15 +286,69 @@ export const SettingsPage: React.FC = () => { const onSave = async (data: UpdateSettingsInput) => { if (!settings) return + if (data.enableBasicAuth && !settings.basicAuthActive) { + const password = data.basicAuthPassword?.trim() ?? "" + if (!password) { + setError("basicAuthPassword", { + type: "manual", + message: "Password is required when basic auth is enabled", + }) + return + } + } try { setIsSaving(true) - + // Prepare payload: nullify if equal to default const resumeProjectsData = data.resumeProjects const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects)) ? null : resumeProjectsData + const envPayload: Partial = {} + + if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) { + envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail) + } + + if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) { + envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail) + } + + if (data.enableBasicAuth === false) { + envPayload.basicAuthUser = null + envPayload.basicAuthPassword = null + } else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) { + // If enabling basic auth or changing either field, ensure we send at least the username + // to keep the pair consistent in the backend. + envPayload.basicAuthUser = normalizeString(data.basicAuthUser) + + if (dirtyFields.basicAuthPassword) { + const value = normalizePrivateInput(data.basicAuthPassword) + if (value !== undefined) envPayload.basicAuthPassword = value + } + } + + if (dirtyFields.openrouterApiKey) { + const value = normalizePrivateInput(data.openrouterApiKey) + if (value !== undefined) envPayload.openrouterApiKey = value + } + + if (dirtyFields.rxresumePassword) { + const value = normalizePrivateInput(data.rxresumePassword) + if (value !== undefined) envPayload.rxresumePassword = value + } + + if (dirtyFields.ukvisajobsPassword) { + const value = normalizePrivateInput(data.ukvisajobsPassword) + if (value !== undefined) envPayload.ukvisajobsPassword = value + } + + if (dirtyFields.webhookSecret) { + const value = normalizePrivateInput(data.webhookSecret) + if (value !== undefined) envPayload.webhookSecret = value + } + const payload: UpdateSettingsInput = { model: normalizeString(data.model), modelScorer: normalizeString(data.modelScorer), @@ -266,8 +370,14 @@ export const SettingsPage: React.FC = () => { jobspy.linkedinFetchDescription.default ), showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), + ...envPayload, } + // Remove virtual field because the backend doesn't expect it + // this exists only to toggle the UI + // need to track it so that the save button is enabled when it changes + delete payload.enableBasicAuth + const updated = await api.updateSettings(payload) setSettings(updated) reset(mapSettingsToForm(updated)) @@ -365,13 +475,10 @@ export const SettingsPage: React.FC = () => { isLoading={isLoading} isSaving={isSaving} /> - - @@ -407,6 +514,11 @@ export const SettingsPage: React.FC = () => { isLoading={isLoading} isSaving={isSaving} /> + { + const methods = useForm({ + defaultValues: { + rxresumeEmail: "resume@example.com", + ukvisajobsEmail: "visa@example.com", + basicAuthUser: "admin", + openrouterApiKey: "", + rxresumePassword: "", + ukvisajobsPassword: "", + basicAuthPassword: "", + webhookSecret: "", + enableBasicAuth: true, + } + }) + + return ( + + + + + + ) +} + +describe("EnvironmentSettingsSection", () => { + it("renders values grouped logically and masks private secrets with hints", () => { + render() + + expect(screen.getByDisplayValue("resume@example.com")).toBeInTheDocument() + expect(screen.getByDisplayValue("visa@example.com")).toBeInTheDocument() + + expect(screen.getByText(/sk-1\*{8}/)).toBeInTheDocument() + expect(screen.getByText(/pass\*{8}/)).toBeInTheDocument() + expect(screen.getByText(/abcd\*{8}/)).toBeInTheDocument() + expect(screen.getByText("Not set")).toBeInTheDocument() + + // Basic Auth + expect(screen.getByLabelText("Enable basic authentication")).toBeChecked() + expect(screen.getByDisplayValue("admin")).toBeInTheDocument() + + // Sections + expect(screen.getByText("External Services")).toBeInTheDocument() + expect(screen.getByText("Service Accounts")).toBeInTheDocument() + expect(screen.getByText("Security")).toBeInTheDocument() + }) +}) diff --git a/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx new file mode 100644 index 0000000..d0e97cf --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/EnvironmentSettingsSection.tsx @@ -0,0 +1,159 @@ +import React from "react" +import { useFormContext, Controller } from "react-hook-form" + +import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Checkbox } from "@/components/ui/checkbox" +import { Separator } from "@/components/ui/separator" +import { UpdateSettingsInput } from "@shared/settings-schema" +import type { EnvSettingsValues } from "@client/pages/settings/types" +import { formatSecretHint } from "@client/pages/settings/utils" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" + +type EnvironmentSettingsSectionProps = { + values: EnvSettingsValues + isLoading: boolean + isSaving: boolean +} + +export const EnvironmentSettingsSection: React.FC = ({ + values, + isLoading, + isSaving, +}) => { + const { register, control, watch, formState: { errors } } = useFormContext() + const { private: privateValues } = values + + const isBasicAuthEnabled = watch("enableBasicAuth") + + return ( + + + Environment & Accounts + + +
+ {/* External Services */} +
+
External Services
+
+ +
+
+ + + + {/* Service Accounts */} +
+
Service Accounts
+ +
+
RxResume
+
+ + +
+
+ +
+
UKVisaJobs
+
+ + +
+
+
+ + + + {/* Security */} +
+
Security
+
+ ( + + )} + /> +
+ +

+ Require a username and password for write operations. +

+
+
+ + {isBasicAuthEnabled && ( +
+ + + +
+ )} +
+
+
+
+ ) +} diff --git a/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx b/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx index a8cdda3..aa540fc 100644 --- a/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx +++ b/orchestrator/src/client/pages/settings/components/GradcrackerSection.tsx @@ -2,10 +2,9 @@ import React from "react" import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { NumericSettingValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type GradcrackerSectionProps = { values: NumericSettingValues @@ -28,48 +27,35 @@ export const GradcrackerSection: React.FC = ({
-
-
Max jobs per search term
- ( - { + ( + { const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { field.onChange(null) } else { field.onChange(Math.min(1000, Math.max(1, value))) } - }} - disabled={isLoading || isSaving} - /> - )} - /> - {errors.gradcrackerMaxJobsPerTerm &&

{errors.gradcrackerMaxJobsPerTerm.message}

} -
- Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000. -
-
- - - -
-
-
Effective
-
{effectiveGradcrackerMaxJobsPerTerm}
-
-
-
Default
-
{defaultGradcrackerMaxJobsPerTerm}
-
-
+ }, + }} + disabled={isLoading || isSaving} + error={errors.gradcrackerMaxJobsPerTerm?.message as string | undefined} + helper={`Maximum number of jobs to fetch for EACH search term from Gradcracker. Default: ${defaultGradcrackerMaxJobsPerTerm}. Range: 1-1000.`} + current={String(effectiveGradcrackerMaxJobsPerTerm)} + /> + )} + />
diff --git a/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx b/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx deleted file mode 100644 index 1d4b775..0000000 --- a/orchestrator/src/client/pages/settings/components/JobCompleteWebhookSection.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react" -import { useFormContext } from "react-hook-form" - -import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { UpdateSettingsInput } from "@shared/settings-schema" -import type { WebhookValues } from "@client/pages/settings/types" - -type JobCompleteWebhookSectionProps = { - values: WebhookValues - isLoading: boolean - isSaving: boolean -} - -export const JobCompleteWebhookSection: React.FC = ({ - values, - isLoading, - isSaving, -}) => { - const { default: defaultJobCompleteWebhookUrl, effective: effectiveJobCompleteWebhookUrl } = values - const { register, formState: { errors } } = useFormContext() - - return ( - - - Job Complete Webhook - - -
-
-
Job completion webhook URL
- - {errors.jobCompleteWebhookUrl &&

{errors.jobCompleteWebhookUrl.message}

} -
- When set, the server sends a POST when you mark a job as applied (includes the job description). -
-
- - - -
-
-
Effective
-
{effectiveJobCompleteWebhookUrl || "—"}
-
-
-
Default (env)
-
{defaultJobCompleteWebhookUrl || "—"}
-
-
-
-
-
- ) -} diff --git a/orchestrator/src/client/pages/settings/components/JobspySection.tsx b/orchestrator/src/client/pages/settings/components/JobspySection.tsx index f004786..fa25071 100644 --- a/orchestrator/src/client/pages/settings/components/JobspySection.tsx +++ b/orchestrator/src/client/pages/settings/components/JobspySection.tsx @@ -3,10 +3,10 @@ import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { JobspyValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type JobspySectionProps = { values: JobspyValues @@ -99,107 +99,85 @@ export const JobspySection: React.FC = ({
-
-
Location
- - {errors.jobspyLocation &&

{errors.jobspyLocation.message}

} -
- Location to search for jobs (e.g. "UK", "London", "Remote"). -
-
- Effective: {location.effective || "—"} - Default: {location.default || "—"} -
-
+ -
-
Results Wanted
- ( - { + ( + { const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { field.onChange(null) } else { field.onChange(Math.min(1000, Math.max(1, value))) } - }} - disabled={isLoading || isSaving} - /> - )} - /> - {errors.jobspyResultsWanted &&

{errors.jobspyResultsWanted.message}

} -
- Number of results to fetch per term per site. Max 1000. -
-
- Effective: {resultsWanted.effective} - Default: {resultsWanted.default} -
-
+ }, + }} + disabled={isLoading || isSaving} + error={errors.jobspyResultsWanted?.message as string | undefined} + helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`} + current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`} + /> + )} + /> -
-
Hours Old
- ( - { + ( + { const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { field.onChange(null) } else { field.onChange(Math.min(720, Math.max(1, value))) } - }} - disabled={isLoading || isSaving} - /> - )} - /> - {errors.jobspyHoursOld &&

{errors.jobspyHoursOld.message}

} -
- Max age of jobs in hours (e.g. 72 for 3 days). Max 720 (30 days). -
-
- Effective: {hoursOld.effective}h - Default: {hoursOld.default}h -
-
+ }, + }} + disabled={isLoading || isSaving} + error={errors.jobspyHoursOld?.message as string | undefined} + helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`} + current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`} + /> + )} + /> -
-
Indeed Country
- - {errors.jobspyCountryIndeed &&

{errors.jobspyCountryIndeed.message}

} -
- Country domain for Indeed (e.g. "UK" for indeed.co.uk). -
-
- Effective: {countryIndeed.effective || "—"} - Default: {countryIndeed.default || "—"} -
-
+
diff --git a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx index b08e414..7632ee2 100644 --- a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx @@ -2,10 +2,10 @@ import React from "react" import { useFormContext } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { ModelValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type ModelSettingsSectionProps = { values: ModelValues @@ -28,18 +28,15 @@ export const ModelSettingsSection: React.FC = ({
-
-
Override model
- - {errors.model &&

{errors.model.message}

} -
- Leave blank to use the default from server env (`MODEL`). -
-
+ @@ -47,44 +44,32 @@ export const ModelSettingsSection: React.FC = ({
Task-Specific Overrides
-
-
Scoring Model
- - {errors.modelScorer &&

{errors.modelScorer.message}

} -
- Effective: {scorer || effective} -
-
+ -
-
Tailoring Model
- - {errors.modelTailoring &&

{errors.modelTailoring.message}

} -
- Effective: {tailoring || effective} -
-
+ -
-
Project Selection Model
- - {errors.modelProjectSelection &&

{errors.modelProjectSelection.message}

} -
- Effective: {projectSelection || effective} -
-
+
diff --git a/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx b/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx deleted file mode 100644 index 8d92275..0000000 --- a/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react" -import { useFormContext } from "react-hook-form" - -import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { UpdateSettingsInput } from "@shared/settings-schema" -import type { WebhookValues } from "@client/pages/settings/types" - -type PipelineWebhookSectionProps = { - values: WebhookValues - isLoading: boolean - isSaving: boolean -} - -export const PipelineWebhookSection: React.FC = ({ - values, - isLoading, - isSaving, -}) => { - const { default: defaultPipelineWebhookUrl, effective: effectivePipelineWebhookUrl } = values - const { register, formState: { errors } } = useFormContext() - - return ( - - - Pipeline Webhook - - -
-
-
Pipeline status webhook URL
- - {errors.pipelineWebhookUrl &&

{errors.pipelineWebhookUrl.message}

} -
- When set, the server sends a POST on pipeline completion/failure. Leave blank to disable. -
-
- - - -
-
-
Effective
-
{effectivePipelineWebhookUrl || "—"}
-
-
-
Default (env)
-
{defaultPipelineWebhookUrl || "—"}
-
-
-
-
-
- ) -} diff --git a/orchestrator/src/client/pages/settings/components/SettingsInput.tsx b/orchestrator/src/client/pages/settings/components/SettingsInput.tsx new file mode 100644 index 0000000..8b66ff8 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/SettingsInput.tsx @@ -0,0 +1,45 @@ +import React from "react" + +import { Input } from "@/components/ui/input" + +type SettingsInputProps = { + label: string + inputProps: React.InputHTMLAttributes + placeholder?: string + type?: React.HTMLInputTypeAttribute + disabled?: boolean + error?: string + helper?: string + current?: string | null +} + +export const SettingsInput: React.FC = ({ + label, + inputProps, + placeholder, + type = "text", + disabled, + error, + helper, + current, +}) => { + const id = inputProps.id || inputProps.name + + return ( +
+ {label && ( + + )} + + {error &&

{error}

} + {helper &&
{helper}
} + {current !== undefined && ( +
+ Current: {current} +
+ )} +
+ ) +} diff --git a/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx b/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx index 05fce6c..fd85efe 100644 --- a/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/UkvisajobsSection.tsx @@ -2,10 +2,9 @@ import React from "react" import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" import { UpdateSettingsInput } from "@shared/settings-schema" import type { NumericSettingValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type UkvisajobsSectionProps = { values: NumericSettingValues @@ -28,48 +27,35 @@ export const UkvisajobsSection: React.FC = ({
-
-
Max jobs to fetch
- ( - { + ( + { const value = parseInt(event.target.value, 10) if (Number.isNaN(value)) { field.onChange(null) } else { field.onChange(Math.min(1000, Math.max(1, value))) } - }} - disabled={isLoading || isSaving} - /> - )} - /> - {errors.ukvisajobsMaxJobs &&

{errors.ukvisajobsMaxJobs.message}

} -
- Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000. -
-
- - - -
-
-
Effective
-
{effectiveUkvisajobsMaxJobs}
-
-
-
Default
-
{defaultUkvisajobsMaxJobs}
-
-
+ }, + }} + disabled={isLoading || isSaving} + error={errors.ukvisajobsMaxJobs?.message as string | undefined} + helper={`Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Default: ${defaultUkvisajobsMaxJobs}. Range: 1-1000.`} + current={String(effectiveUkvisajobsMaxJobs)} + /> + )} + />
diff --git a/orchestrator/src/client/pages/settings/components/WebhooksSection.test.tsx b/orchestrator/src/client/pages/settings/components/WebhooksSection.test.tsx new file mode 100644 index 0000000..1547fa2 --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/WebhooksSection.test.tsx @@ -0,0 +1,50 @@ +import { render, screen } from "@testing-library/react" +import { useForm, FormProvider } from "react-hook-form" + +import { Accordion } from "@/components/ui/accordion" +import { WebhooksSection } from "./WebhooksSection" +import { UpdateSettingsInput } from "@shared/settings-schema" + +const WebhooksHarness = () => { + const methods = useForm({ + defaultValues: { + pipelineWebhookUrl: "https://pipeline.com", + jobCompleteWebhookUrl: "https://job.com", + webhookSecret: "", + } + }) + + return ( + + + + + + ) +} + +describe("WebhooksSection", () => { + it("renders both webhook sections and the secret", () => { + render() + + expect(screen.getByText("Pipeline Status")).toBeInTheDocument() + expect(screen.getByText("Job Completion")).toBeInTheDocument() + + expect(screen.getByDisplayValue("https://pipeline.com")).toBeInTheDocument() + expect(screen.getByDisplayValue("https://job.com")).toBeInTheDocument() + + expect(screen.getByText("sec-********")).toBeInTheDocument() + }) +}) diff --git a/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx b/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx new file mode 100644 index 0000000..305488f --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/WebhooksSection.tsx @@ -0,0 +1,79 @@ +import React from "react" +import { useFormContext } from "react-hook-form" + +import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" +import { Separator } from "@/components/ui/separator" +import { UpdateSettingsInput } from "@shared/settings-schema" +import type { WebhookValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" +import { formatSecretHint } from "@client/pages/settings/utils" + +type WebhooksSectionProps = { + pipelineWebhook: WebhookValues + jobCompleteWebhook: WebhookValues + webhookSecretHint: string | null + isLoading: boolean + isSaving: boolean +} + +export const WebhooksSection: React.FC = ({ + pipelineWebhook, + jobCompleteWebhook, + webhookSecretHint, + isLoading, + isSaving, +}) => { + const { register, formState: { errors } } = useFormContext() + + return ( + + + Webhooks + + +
+
+
Pipeline Status
+ +
+ + + +
+
Job Completion
+
+ + + +
+
+
+
+
+ ) +} diff --git a/orchestrator/src/client/pages/settings/types.ts b/orchestrator/src/client/pages/settings/types.ts index 227be46..c75cf80 100644 --- a/orchestrator/src/client/pages/settings/types.ts +++ b/orchestrator/src/client/pages/settings/types.ts @@ -22,3 +22,19 @@ export type JobspyValues = { countryIndeed: EffectiveDefault linkedinFetchDescription: EffectiveDefault } + +export type EnvSettingsValues = { + readable: { + rxresumeEmail: string + ukvisajobsEmail: string + basicAuthUser: string + } + private: { + openrouterApiKeyHint: string | null + rxresumePasswordHint: string | null + ukvisajobsPasswordHint: string | null + basicAuthPasswordHint: string | null + webhookSecretHint: string | null + } + basicAuthActive: boolean +} diff --git a/orchestrator/src/client/pages/settings/utils.ts b/orchestrator/src/client/pages/settings/utils.ts index 181d985..20752ff 100644 --- a/orchestrator/src/client/pages/settings/utils.ts +++ b/orchestrator/src/client/pages/settings/utils.ts @@ -12,3 +12,5 @@ export function resumeProjectsEqual(a: ResumeProjectsSettings, b: ResumeProjects arraysEqual(a.aiSelectableProjectIds, b.aiSelectableProjectIds) ) } + +export const formatSecretHint = (hint: string | null) => (hint ? `${hint}********` : "Not set") diff --git a/orchestrator/src/server/api/routes/settings.test.ts b/orchestrator/src/server/api/routes/settings.test.ts index 1e13474..06fd064 100644 --- a/orchestrator/src/server/api/routes/settings.test.ts +++ b/orchestrator/src/server/api/routes/settings.test.ts @@ -9,7 +9,12 @@ describe.sequential('Settings API routes', () => { let tempDir: string; beforeEach(async () => { - ({ server, baseUrl, closeDb, tempDir } = await startServer()); + ({ server, baseUrl, closeDb, tempDir } = await startServer({ + env: { + OPENROUTER_API_KEY: 'secret-key', + RXRESUME_EMAIL: 'resume@example.com', + }, + })); }); afterEach(async () => { @@ -22,6 +27,9 @@ describe.sequential('Settings API routes', () => { expect(body.success).toBe(true); expect(body.data.defaultModel).toBe('test-model'); expect(Array.isArray(body.data.searchTerms)).toBe(true); + expect(body.data.rxresumeEmail).toBe('resume@example.com'); + expect(body.data.openrouterApiKeyHint).toBe('secr'); + expect(body.data.basicAuthActive).toBe(false); }); it('rejects invalid settings updates and persists overrides', async () => { @@ -35,11 +43,32 @@ describe.sequential('Settings API routes', () => { const patchRes = await fetch(`${baseUrl}/api/settings`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ searchTerms: ['engineer'] }), + body: JSON.stringify({ + searchTerms: ['engineer'], + rxresumeEmail: 'updated@example.com', + openrouterApiKey: 'updated-secret', + }), }); const patchBody = await patchRes.json(); expect(patchBody.success).toBe(true); expect(patchBody.data.searchTerms).toEqual(['engineer']); expect(patchBody.data.overrideSearchTerms).toEqual(['engineer']); + expect(patchBody.data.rxresumeEmail).toBe('updated@example.com'); + expect(patchBody.data.openrouterApiKeyHint).toBe('upda'); + }); + + it('validates basic auth requirements', async () => { + const res = await fetch(`${baseUrl}/api/settings`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enableBasicAuth: true, + basicAuthUser: '', + }), + }); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.error).toContain('Username is required'); }); }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 1813fca..b8ab30d 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -1,12 +1,16 @@ import { Router, Request, Response } from 'express'; import { updateSettingsSchema } from '@shared/settings-schema.js'; import * as settingsRepo from '@server/repositories/settings.js'; +import { + applyEnvValue, + normalizeEnvInput, +} from '@server/services/envSettings.js'; import { extractProjectsFromProfile, normalizeResumeProjectsSettings, - resolveResumeProjectsSettings, } from '@server/services/resumeProjects.js'; import { getProfile } from '@server/services/profile.js'; +import { getEffectiveSettings } from '@server/services/settings.js'; export const settingsRouter = Router(); @@ -15,139 +19,8 @@ export const settingsRouter = Router(); */ settingsRouter.get('/', async (_req: Request, res: Response) => { try { - const overrideModel = await settingsRepo.getSetting('model'); - const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; - const model = overrideModel || defaultModel; - - // Specific AI models - const overrideModelScorer = await settingsRepo.getSetting('modelScorer'); - const modelScorer = overrideModelScorer || model; - - const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring'); - const modelTailoring = overrideModelTailoring || model; - - const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection'); - const modelProjectSelection = overrideModelProjectSelection || model; - - const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl'); - const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ''; - const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl; - - const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl'); - const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || ''; - const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl; - - const profile = await getProfile(); - const { catalog } = extractProjectsFromProfile(profile); - const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects'); - const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw }); - - const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs'); - const defaultUkvisajobsMaxJobs = 50; - const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null; - const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs; - - const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm'); - const defaultGradcrackerMaxJobsPerTerm = 50; - const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null; - const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm; - - const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms'); - const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer'; - const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean); - const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null; - const searchTerms = overrideSearchTerms ?? defaultSearchTerms; - - // JobSpy settings (GET) - const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation'); - const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK'; - const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation; - - const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted'); - const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10); - const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null; - const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted; - - const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld'); - const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10); - const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null; - const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld; - - const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed'); - const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK'; - const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed; - - const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites'); - const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean); - const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null; - const jobspySites = overrideJobspySites ?? defaultJobspySites; - - const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription'); - const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1'; - const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw - ? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1' - : null; - const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription; - - // Show Sponsor Info setting (on by default) - const overrideShowSponsorInfoRaw = await settingsRepo.getSetting('showSponsorInfo'); - const defaultShowSponsorInfo = true; - const overrideShowSponsorInfo = overrideShowSponsorInfoRaw - ? overrideShowSponsorInfoRaw === 'true' || overrideShowSponsorInfoRaw === '1' - : null; - const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo; - - res.json({ - success: true, - data: { - model, - defaultModel, - overrideModel, - modelScorer, - overrideModelScorer, - modelTailoring, - overrideModelTailoring, - modelProjectSelection, - overrideModelProjectSelection, - pipelineWebhookUrl, - defaultPipelineWebhookUrl, - overridePipelineWebhookUrl, - jobCompleteWebhookUrl, - defaultJobCompleteWebhookUrl, - overrideJobCompleteWebhookUrl, - ...resumeProjectsData, - ukvisajobsMaxJobs, - defaultUkvisajobsMaxJobs, - overrideUkvisajobsMaxJobs, - gradcrackerMaxJobsPerTerm, - defaultGradcrackerMaxJobsPerTerm, - overrideGradcrackerMaxJobsPerTerm, - searchTerms, - defaultSearchTerms, - overrideSearchTerms, - jobspyLocation, - defaultJobspyLocation, - overrideJobspyLocation, - jobspyResultsWanted, - defaultJobspyResultsWanted, - overrideJobspyResultsWanted, - jobspyHoursOld, - defaultJobspyHoursOld, - overrideJobspyHoursOld, - jobspyCountryIndeed, - defaultJobspyCountryIndeed, - overrideJobspyCountryIndeed, - jobspySites, - defaultJobspySites, - overrideJobspySites, - jobspyLinkedinFetchDescription, - defaultJobspyLinkedinFetchDescription, - overrideJobspyLinkedinFetchDescription, - showSponsorInfo, - defaultShowSponsorInfo, - overrideShowSponsorInfo, - }, - }); + const data = await getEffectiveSettings(); + res.json({ success: true, data }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; res.status(500).json({ success: false, error: message }); @@ -160,239 +33,162 @@ settingsRouter.get('/', async (_req: Request, res: Response) => { settingsRouter.patch('/', async (req: Request, res: Response) => { try { const input = updateSettingsSchema.parse(req.body); + const promises: Promise[] = []; if ('model' in input) { - const model = input.model ?? null; - await settingsRepo.setSetting('model', model); + promises.push(settingsRepo.setSetting('model', input.model ?? null)); } if ('modelScorer' in input) { - await settingsRepo.setSetting('modelScorer', input.modelScorer ?? null); + promises.push(settingsRepo.setSetting('modelScorer', input.modelScorer ?? null)); } if ('modelTailoring' in input) { - await settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null); + promises.push(settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null)); } if ('modelProjectSelection' in input) { - await settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null); + promises.push(settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null)); } if ('pipelineWebhookUrl' in input) { - const pipelineWebhookUrl = input.pipelineWebhookUrl ?? null; - await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl); + promises.push(settingsRepo.setSetting('pipelineWebhookUrl', input.pipelineWebhookUrl ?? null)); } if ('jobCompleteWebhookUrl' in input) { - const webhookUrl = input.jobCompleteWebhookUrl ?? null; - await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl); + promises.push(settingsRepo.setSetting('jobCompleteWebhookUrl', input.jobCompleteWebhookUrl ?? null)); } if ('resumeProjects' in input) { const resumeProjects = input.resumeProjects ?? null; if (resumeProjects === null) { - await settingsRepo.setSetting('resumeProjects', null); + promises.push(settingsRepo.setSetting('resumeProjects', null)); } else { - const rawProfile = await getProfile(); + promises.push((async () => { + const rawProfile = await getProfile(); - if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) { - throw new Error('Invalid resume profile format: expected a non-null object'); - } + if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) { + throw new Error('Invalid resume profile format: expected a non-null object'); + } - const profile = rawProfile as Record; - const { catalog } = extractProjectsFromProfile(profile); - const allowed = new Set(catalog.map((p) => p.id)); - const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed); - await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized)); + const profile = rawProfile as Record; + const { catalog } = extractProjectsFromProfile(profile); + const allowed = new Set(catalog.map((p) => p.id)); + const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed); + await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized)); + })()); } } if ('ukvisajobsMaxJobs' in input) { - const ukvisajobsMaxJobs = input.ukvisajobsMaxJobs ?? null; - await settingsRepo.setSetting('ukvisajobsMaxJobs', ukvisajobsMaxJobs !== null ? String(ukvisajobsMaxJobs) : null); + const val = input.ukvisajobsMaxJobs ?? null; + promises.push(settingsRepo.setSetting('ukvisajobsMaxJobs', val !== null ? String(val) : null)); } if ('gradcrackerMaxJobsPerTerm' in input) { - const gradcrackerMaxJobsPerTerm = input.gradcrackerMaxJobsPerTerm ?? null; - await settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', gradcrackerMaxJobsPerTerm !== null ? String(gradcrackerMaxJobsPerTerm) : null); + const val = input.gradcrackerMaxJobsPerTerm ?? null; + promises.push(settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', val !== null ? String(val) : null)); } if ('searchTerms' in input) { - const searchTerms = input.searchTerms ?? null; - await settingsRepo.setSetting('searchTerms', searchTerms !== null ? JSON.stringify(searchTerms) : null); + const val = input.searchTerms ?? null; + promises.push(settingsRepo.setSetting('searchTerms', val !== null ? JSON.stringify(val) : null)); } if ('jobspyLocation' in input) { - const value = input.jobspyLocation ?? null; - await settingsRepo.setSetting('jobspyLocation', value); + promises.push(settingsRepo.setSetting('jobspyLocation', input.jobspyLocation ?? null)); } if ('jobspyResultsWanted' in input) { - const value = input.jobspyResultsWanted ?? null; - await settingsRepo.setSetting('jobspyResultsWanted', value !== null ? String(value) : null); + const val = input.jobspyResultsWanted ?? null; + promises.push(settingsRepo.setSetting('jobspyResultsWanted', val !== null ? String(val) : null)); } if ('jobspyHoursOld' in input) { - const value = input.jobspyHoursOld ?? null; - await settingsRepo.setSetting('jobspyHoursOld', value !== null ? String(value) : null); + const val = input.jobspyHoursOld ?? null; + promises.push(settingsRepo.setSetting('jobspyHoursOld', val !== null ? String(val) : null)); } if ('jobspyCountryIndeed' in input) { - const value = input.jobspyCountryIndeed ?? null; - await settingsRepo.setSetting('jobspyCountryIndeed', value); + promises.push(settingsRepo.setSetting('jobspyCountryIndeed', input.jobspyCountryIndeed ?? null)); } if ('jobspySites' in input) { - const value = input.jobspySites ?? null; - await settingsRepo.setSetting('jobspySites', value !== null ? JSON.stringify(value) : null); + const val = input.jobspySites ?? null; + promises.push(settingsRepo.setSetting('jobspySites', val !== null ? JSON.stringify(val) : null)); } if ('jobspyLinkedinFetchDescription' in input) { - const value = input.jobspyLinkedinFetchDescription ?? null; - await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null); + const val = input.jobspyLinkedinFetchDescription ?? null; + promises.push(settingsRepo.setSetting('jobspyLinkedinFetchDescription', val !== null ? (val ? '1' : '0') : null)); } if ('showSponsorInfo' in input) { - const value = input.showSponsorInfo ?? null; - await settingsRepo.setSetting('showSponsorInfo', value !== null ? (value ? '1' : '0') : null); + const val = input.showSponsorInfo ?? null; + promises.push(settingsRepo.setSetting('showSponsorInfo', val !== null ? (val ? '1' : '0') : null)); } - const overrideModel = await settingsRepo.getSetting('model'); - const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; - const model = overrideModel || defaultModel; + if ('openrouterApiKey' in input) { + const value = normalizeEnvInput(input.openrouterApiKey); + promises.push(settingsRepo.setSetting('openrouterApiKey', value).then(() => { + applyEnvValue('OPENROUTER_API_KEY', value); + })); + } - const overrideModelScorer = await settingsRepo.getSetting('modelScorer'); - const modelScorer = overrideModelScorer || model; + if ('rxresumeEmail' in input) { + const value = normalizeEnvInput(input.rxresumeEmail); + promises.push(settingsRepo.setSetting('rxresumeEmail', value).then(() => { + applyEnvValue('RXRESUME_EMAIL', value); + })); + } - const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring'); - const modelTailoring = overrideModelTailoring || model; + if ('rxresumePassword' in input) { + const value = normalizeEnvInput(input.rxresumePassword); + promises.push(settingsRepo.setSetting('rxresumePassword', value).then(() => { + applyEnvValue('RXRESUME_PASSWORD', value); + })); + } - const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection'); - const modelProjectSelection = overrideModelProjectSelection || model; + if ('basicAuthUser' in input) { + const value = normalizeEnvInput(input.basicAuthUser); + promises.push(settingsRepo.setSetting('basicAuthUser', value).then(() => { + applyEnvValue('BASIC_AUTH_USER', value); + })); + } - const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl'); - const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ''; - const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl; + if ('basicAuthPassword' in input) { + const value = normalizeEnvInput(input.basicAuthPassword); + promises.push(settingsRepo.setSetting('basicAuthPassword', value).then(() => { + applyEnvValue('BASIC_AUTH_PASSWORD', value); + })); + } - const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl'); - const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || ''; - const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl; + if ('ukvisajobsEmail' in input) { + const value = normalizeEnvInput(input.ukvisajobsEmail); + promises.push(settingsRepo.setSetting('ukvisajobsEmail', value).then(() => { + applyEnvValue('UKVISAJOBS_EMAIL', value); + })); + } - const profile = await getProfile(); - const { catalog } = extractProjectsFromProfile(profile); - const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects'); - const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw }); + if ('ukvisajobsPassword' in input) { + const value = normalizeEnvInput(input.ukvisajobsPassword); + promises.push(settingsRepo.setSetting('ukvisajobsPassword', value).then(() => { + applyEnvValue('UKVISAJOBS_PASSWORD', value); + })); + } - const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs'); - const defaultUkvisajobsMaxJobs = 50; - const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null; - const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs; + if ('webhookSecret' in input) { + const value = normalizeEnvInput(input.webhookSecret); + promises.push(settingsRepo.setSetting('webhookSecret', value).then(() => { + applyEnvValue('WEBHOOK_SECRET', value); + })); + } - const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm'); - const defaultGradcrackerMaxJobsPerTerm = 50; - const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null; - const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm; + await Promise.all(promises); - // Search terms - stored as JSON array, default from env var (pipe-separated) - const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms'); - const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer'; - const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean); - const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null; - const searchTerms = overrideSearchTerms ?? defaultSearchTerms; - - // JobSpy settings (re-fetch to update response) - const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation'); - const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK'; - const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation; - - const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted'); - const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10); - const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null; - const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted; - - const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld'); - const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10); - const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null; - const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld; - - const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed'); - const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK'; - const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed; - - const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites'); - const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean); - const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null; - const jobspySites = overrideJobspySites ?? defaultJobspySites; - - const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription'); - const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1'; - const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw - ? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1' - : null; - const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription; - - // Show Sponsor Info setting - const overrideShowSponsorInfoRaw = await settingsRepo.getSetting('showSponsorInfo'); - const defaultShowSponsorInfo = true; - const overrideShowSponsorInfo = overrideShowSponsorInfoRaw - ? overrideShowSponsorInfoRaw === 'true' || overrideShowSponsorInfoRaw === '1' - : null; - const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo; - - res.json({ - success: true, - data: { - model, - defaultModel, - overrideModel, - modelScorer, - overrideModelScorer, - modelTailoring, - overrideModelTailoring, - modelProjectSelection, - overrideModelProjectSelection, - pipelineWebhookUrl, - defaultPipelineWebhookUrl, - overridePipelineWebhookUrl, - jobCompleteWebhookUrl, - defaultJobCompleteWebhookUrl, - overrideJobCompleteWebhookUrl, - ...resumeProjectsData, - ukvisajobsMaxJobs, - defaultUkvisajobsMaxJobs, - overrideUkvisajobsMaxJobs, - gradcrackerMaxJobsPerTerm, - defaultGradcrackerMaxJobsPerTerm, - overrideGradcrackerMaxJobsPerTerm, - searchTerms, - defaultSearchTerms, - overrideSearchTerms, - jobspyLocation, - defaultJobspyLocation, - overrideJobspyLocation, - jobspyResultsWanted, - defaultJobspyResultsWanted, - overrideJobspyResultsWanted, - jobspyHoursOld, - defaultJobspyHoursOld, - overrideJobspyHoursOld, - jobspyCountryIndeed, - defaultJobspyCountryIndeed, - overrideJobspyCountryIndeed, - jobspySites, - defaultJobspySites, - overrideJobspySites, - jobspyLinkedinFetchDescription, - defaultJobspyLinkedinFetchDescription, - overrideJobspyLinkedinFetchDescription, - showSponsorInfo, - defaultShowSponsorInfo, - overrideShowSponsorInfo, - }, - }); + const data = await getEffectiveSettings(); + res.json({ success: true, data }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; - // PATCH usually returns 500 for unknown, but let's stick to what was there (400?) - // Wait, the file said 400? Let's verify line 608. res.status(400).json({ success: false, error: message }); } }); diff --git a/orchestrator/src/server/api/routes/test-utils.ts b/orchestrator/src/server/api/routes/test-utils.ts index 246f0aa..8787a9d 100644 --- a/orchestrator/src/server/api/routes/test-utils.ts +++ b/orchestrator/src/server/api/routes/test-utils.ts @@ -86,11 +86,14 @@ export async function startServer(options?: { }; await import('../../db/migrate.js'); + const { applyStoredEnvOverrides } = await import('../../services/envSettings.js'); const { createApp } = await import('../../app.js'); const { closeDb } = await import('../../db/index.js'); const { getPipelineStatus } = await import('../../pipeline/index.js'); vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false }); + await applyStoredEnvOverrides(); + const app = createApp(); const server = app.listen(0); await new Promise((resolve) => server.once('listening', () => resolve())); diff --git a/orchestrator/src/server/app.ts b/orchestrator/src/server/app.ts index b592371..4f39077 100644 --- a/orchestrator/src/server/app.ts +++ b/orchestrator/src/server/app.ts @@ -13,12 +13,19 @@ import { getDataDir } from './config/dataDir.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); function createBasicAuthGuard() { - const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER || ''; - const BASIC_AUTH_PASSWORD = process.env.BASIC_AUTH_PASSWORD || ''; - const basicAuthEnabled = BASIC_AUTH_USER.length > 0 && BASIC_AUTH_PASSWORD.length > 0; + function getAuthConfig() { + const user = process.env.BASIC_AUTH_USER || ''; + const pass = process.env.BASIC_AUTH_PASSWORD || ''; + return { + user, + pass, + enabled: user.length > 0 && pass.length > 0, + }; + } function isAuthorized(req: express.Request): boolean { - if (!basicAuthEnabled) return false; + const { user: authUser, pass: authPass, enabled } = getAuthConfig(); + if (!enabled) return false; const authHeader = req.headers.authorization || ''; if (!authHeader.startsWith('Basic ')) return false; const encoded = authHeader.slice('Basic '.length).trim(); @@ -32,7 +39,7 @@ function createBasicAuthGuard() { if (separatorIndex === -1) return false; const user = decoded.slice(0, separatorIndex); const pass = decoded.slice(separatorIndex + 1); - return user === BASIC_AUTH_USER && pass === BASIC_AUTH_PASSWORD; + return user === authUser && pass === authPass; } function isPublicReadOnlyRoute(method: string, path: string): boolean { @@ -48,7 +55,8 @@ function createBasicAuthGuard() { } const middleware = (req: express.Request, res: express.Response, next: express.NextFunction) => { - if (!basicAuthEnabled || !requiresAuth(req.method, req.path)) return next(); + const { enabled } = getAuthConfig(); + if (!enabled || !requiresAuth(req.method, req.path)) return next(); if (isAuthorized(req)) return next(); res.setHeader('WWW-Authenticate', 'Basic realm="Job Ops"'); res.status(401).send('Authentication required'); @@ -57,7 +65,7 @@ function createBasicAuthGuard() { return { middleware, isAuthorized, - basicAuthEnabled, + basicAuthEnabled: getAuthConfig().enabled, }; } diff --git a/orchestrator/src/server/index.ts b/orchestrator/src/server/index.ts index df29e1b..fc7e212 100644 --- a/orchestrator/src/server/index.ts +++ b/orchestrator/src/server/index.ts @@ -4,14 +4,18 @@ import './config/env.js'; import { createApp } from './app.js'; +import { applyStoredEnvOverrides } from './services/envSettings.js'; import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js'; -const app = createApp(); -const PORT = process.env.PORT || 3001; +async function startServer() { + await applyStoredEnvOverrides(); -// Start server -app.listen(PORT, async () => { - console.log(` + const app = createApp(); + const PORT = process.env.PORT || 3001; + + // Start server + app.listen(PORT, async () => { + console.log(` ╔═══════════════════════════════════════════════════════════╗ ║ ║ ║ 🚀 Job Ops Orchestrator ║ @@ -25,10 +29,13 @@ app.listen(PORT, async () => { ╚═══════════════════════════════════════════════════════════╝ `); - // Initialize visa sponsors service (downloads data if needed, starts scheduler) - try { - await initializeVisaSponsors(); - } catch (error) { - console.warn('⚠️ Failed to initialize visa sponsors service:', error); - } -}); + // Initialize visa sponsors service (downloads data if needed, starts scheduler) + try { + await initializeVisaSponsors(); + } catch (error) { + console.warn('⚠️ Failed to initialize visa sponsors service:', error); + } + }); +} + +void startServer(); diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index c79448b..620e990 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -121,8 +121,11 @@ export async function runPipeline(config: Partial = {}): Promise const discoveredJobs: CreateJobInput[] = []; const sourceErrors: string[] = []; + // Read all settings at once to avoid sequential DB calls + const settings = await settingsRepo.getAllSettings(); + // Read search terms setting - const searchTermsSetting = await settingsRepo.getSetting('searchTerms'); + const searchTermsSetting = settings.searchTerms; let searchTerms: string[] = []; if (searchTermsSetting) { @@ -139,7 +142,7 @@ export async function runPipeline(config: Partial = {}): Promise ); // Apply setting override for JobSpy sites - const jobspySitesSettingRaw = await settingsRepo.getSetting('jobspySites'); + const jobspySitesSettingRaw = settings.jobspySites; if (jobspySitesSettingRaw) { try { const allowed = JSON.parse(jobspySitesSettingRaw); @@ -157,11 +160,11 @@ export async function runPipeline(config: Partial = {}): Promise detail: `JobSpy: scraping ${jobSpySites.join(', ')}...`, }); - const jobspyLocationSetting = await settingsRepo.getSetting('jobspyLocation'); - const jobspyResultsWantedSetting = await settingsRepo.getSetting('jobspyResultsWanted'); - const jobspyHoursOldSetting = await settingsRepo.getSetting('jobspyHoursOld'); - const jobspyCountryIndeedSetting = await settingsRepo.getSetting('jobspyCountryIndeed'); - const jobspyLinkedinFetchDescriptionSetting = await settingsRepo.getSetting('jobspyLinkedinFetchDescription'); + const jobspyLocationSetting = settings.jobspyLocation; + const jobspyResultsWantedSetting = settings.jobspyResultsWanted; + const jobspyHoursOldSetting = settings.jobspyHoursOld; + const jobspyCountryIndeedSetting = settings.jobspyCountryIndeed; + const jobspyLinkedinFetchDescriptionSetting = settings.jobspyLinkedinFetchDescription; const jobSpyResult = await runJobSpy({ sites: jobSpySites, @@ -170,7 +173,7 @@ export async function runPipeline(config: Partial = {}): Promise resultsWanted: jobspyResultsWantedSetting ? parseInt(jobspyResultsWantedSetting, 10) : undefined, hoursOld: jobspyHoursOldSetting ? parseInt(jobspyHoursOldSetting, 10) : undefined, countryIndeed: jobspyCountryIndeedSetting ?? undefined, - linkedinFetchDescription: jobspyLinkedinFetchDescriptionSetting !== null ? jobspyLinkedinFetchDescriptionSetting === '1' : undefined, + linkedinFetchDescription: jobspyLinkedinFetchDescriptionSetting !== null && jobspyLinkedinFetchDescriptionSetting !== undefined ? jobspyLinkedinFetchDescriptionSetting === '1' : undefined, }); if (!jobSpyResult.success) { sourceErrors.push(`jobspy: ${jobSpyResult.error ?? 'unknown error'}`); @@ -189,7 +192,7 @@ export async function runPipeline(config: Partial = {}): Promise // Pass existing URLs to avoid clicking "Apply" on jobs we already have const existingJobUrls = await jobsRepo.getAllJobUrls(); - const gradcrackerMaxJobsSetting = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm'); + const gradcrackerMaxJobsSetting = settings.gradcrackerMaxJobsPerTerm; const gradcrackerMaxJobs = gradcrackerMaxJobsSetting ? parseInt(gradcrackerMaxJobsSetting, 10) : 50; const crawlerResult = await runCrawler({ @@ -224,7 +227,7 @@ export async function runPipeline(config: Partial = {}): Promise }); // Read max jobs setting from database (default to 50 if not set) - const ukvisajobsMaxJobsSetting = await settingsRepo.getSetting('ukvisajobsMaxJobs'); + const ukvisajobsMaxJobsSetting = settings.ukvisajobsMaxJobs; const ukvisajobsMaxJobs = ukvisajobsMaxJobsSetting ? parseInt(ukvisajobsMaxJobsSetting, 10) : 50; const ukVisaResult = await runUkVisaJobs({ diff --git a/orchestrator/src/server/pipeline/sponsor-matching.test.ts b/orchestrator/src/server/pipeline/sponsor-matching.test.ts index 5ef0daa..1f2dd9e 100644 --- a/orchestrator/src/server/pipeline/sponsor-matching.test.ts +++ b/orchestrator/src/server/pipeline/sponsor-matching.test.ts @@ -36,6 +36,7 @@ vi.mock('../repositories/pipeline.js', () => ({ vi.mock('../repositories/settings.js', () => ({ getSetting: vi.fn().mockResolvedValue(null), + getAllSettings: vi.fn().mockResolvedValue({}), })); vi.mock('../services/crawler.js', () => ({ diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 36b6f19..6f98678 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -24,12 +24,28 @@ export type SettingKey = 'model' | 'jobspySites' | 'jobspyLinkedinFetchDescription' | 'showSponsorInfo' + | 'openrouterApiKey' + | 'rxresumeEmail' + | 'rxresumePassword' + | 'basicAuthUser' + | 'basicAuthPassword' + | 'ukvisajobsEmail' + | 'ukvisajobsPassword' + | 'webhookSecret' export async function getSetting(key: SettingKey): Promise { const [row] = await db.select().from(settings).where(eq(settings.key, key)) return row?.value ?? null } +export async function getAllSettings(): Promise>> { + const rows = await db.select().from(settings) + return rows.reduce((acc, row) => { + acc[row.key as SettingKey] = row.value + return acc + }, {} as Partial>) +} + export async function setSetting(key: SettingKey, value: string | null): Promise { const now = new Date().toISOString() diff --git a/orchestrator/src/server/services/envSettings.ts b/orchestrator/src/server/services/envSettings.ts new file mode 100644 index 0000000..6137ccb --- /dev/null +++ b/orchestrator/src/server/services/envSettings.ts @@ -0,0 +1,118 @@ +import * as settingsRepo from '@server/repositories/settings.js'; +import { SettingKey } from '@server/repositories/settings.js'; + +const envDefaults: Record = { ...process.env }; + +const readableStringConfig: { settingKey: SettingKey, envKey: string }[] = [ + { settingKey: 'rxresumeEmail', envKey: 'RXRESUME_EMAIL' }, + { settingKey: 'ukvisajobsEmail', envKey: 'UKVISAJOBS_EMAIL' }, + { settingKey: 'basicAuthUser', envKey: 'BASIC_AUTH_USER' }, +]; + +const readableBooleanConfig: { settingKey: SettingKey, envKey: string, defaultValue: boolean }[] = []; + +const privateStringConfig: { settingKey: SettingKey, envKey: string, hintKey: string }[] = [ + { settingKey: 'openrouterApiKey', envKey: 'OPENROUTER_API_KEY', hintKey: 'openrouterApiKeyHint' }, + { settingKey: 'rxresumePassword', envKey: 'RXRESUME_PASSWORD', hintKey: 'rxresumePasswordHint' }, + { settingKey: 'ukvisajobsPassword', envKey: 'UKVISAJOBS_PASSWORD', hintKey: 'ukvisajobsPasswordHint' }, + { settingKey: 'basicAuthPassword', envKey: 'BASIC_AUTH_PASSWORD', hintKey: 'basicAuthPasswordHint' }, + { settingKey: 'webhookSecret', envKey: 'WEBHOOK_SECRET', hintKey: 'webhookSecretHint' }, +]; + +export function normalizeEnvInput(value: string | null | undefined): string | null { + const trimmed = value?.trim(); + 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]; + if (fallback === undefined) { + delete process.env[envKey]; + } else { + process.env[envKey] = fallback; + } + return; + } + + 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 { + await Promise.all([ + ...readableStringConfig.map(async ({ settingKey, envKey }) => { + const override = await settingsRepo.getSetting(settingKey); + if (override === null) return; + applyEnvValue(envKey, normalizeEnvInput(override)); + }), + ...readableBooleanConfig.map(async ({ settingKey, envKey, defaultValue }) => { + const override = await settingsRepo.getSetting(settingKey); + if (override === null) return; + const parsed = parseEnvBoolean(override, defaultValue); + applyEnvValue(envKey, serializeEnvBoolean(parsed)); + }), + ...privateStringConfig.map(async ({ settingKey, envKey }) => { + const override = await settingsRepo.getSetting(settingKey); + if (override === null) return; + applyEnvValue(envKey, normalizeEnvInput(override)); + }), + ]); +} + +export async function getEnvSettingsData( + overrides?: Partial> +): Promise> { + const activeOverrides = overrides || await settingsRepo.getAllSettings(); + const readableValues: Record = {}; + const privateValues: Record = {}; + + for (const { settingKey, envKey } of readableStringConfig) { + const override = activeOverrides[settingKey] ?? null; + const rawValue = override ?? process.env[envKey]; + readableValues[settingKey] = normalizeEnvInput(rawValue); + } + + for (const { settingKey, envKey, defaultValue } of readableBooleanConfig) { + const override = activeOverrides[settingKey] ?? null; + const rawValue = override ?? process.env[envKey]; + readableValues[settingKey] = parseEnvBoolean(rawValue, defaultValue); + } + + for (const { settingKey, envKey, hintKey } of privateStringConfig) { + const override = activeOverrides[settingKey] ?? null; + const rawValue = override ?? process.env[envKey]; + if (!rawValue) { + privateValues[hintKey] = null; + continue; + } + + const hintLength = rawValue.length > 4 ? 4 : Math.max(rawValue.length - 1, 1); + privateValues[hintKey] = rawValue.slice(0, hintLength); + } + + const basicAuthUser = activeOverrides['basicAuthUser'] ?? process.env.BASIC_AUTH_USER; + const basicAuthPassword = activeOverrides['basicAuthPassword'] ?? process.env.BASIC_AUTH_PASSWORD; + + return { + ...readableValues, + ...privateValues, + basicAuthActive: Boolean(basicAuthUser && basicAuthPassword), + }; +} + +export const envSettingConfig = { + readableStringConfig, + readableBooleanConfig, + privateStringConfig, +}; diff --git a/orchestrator/src/server/services/manualJob.test.ts b/orchestrator/src/server/services/manualJob.test.ts index 00321c4..dd6277f 100644 --- a/orchestrator/src/server/services/manualJob.test.ts +++ b/orchestrator/src/server/services/manualJob.test.ts @@ -4,6 +4,7 @@ import { inferManualJobDetails } from "./manualJob.js"; vi.mock("../repositories/settings.js", () => ({ getSetting: vi.fn(), + getAllSettings: vi.fn().mockResolvedValue({}), })); const originalEnv = process.env; diff --git a/orchestrator/src/server/services/pdf-skills-validation.test.ts b/orchestrator/src/server/services/pdf-skills-validation.test.ts index 785e69c..d106ea1 100644 --- a/orchestrator/src/server/services/pdf-skills-validation.test.ts +++ b/orchestrator/src/server/services/pdf-skills-validation.test.ts @@ -47,6 +47,7 @@ vi.mock('fs', () => ({ vi.mock('../repositories/settings.js', () => ({ getSetting: vi.fn().mockResolvedValue(null), + getAllSettings: vi.fn().mockResolvedValue({}), })); vi.mock('./projectSelection.js', () => ({ diff --git a/orchestrator/src/server/services/pdf-tailoring.test.ts b/orchestrator/src/server/services/pdf-tailoring.test.ts index ab8ae6e..df187fe 100644 --- a/orchestrator/src/server/services/pdf-tailoring.test.ts +++ b/orchestrator/src/server/services/pdf-tailoring.test.ts @@ -49,6 +49,7 @@ vi.mock('fs', () => ({ vi.mock('../repositories/settings.js', () => ({ getSetting: vi.fn().mockResolvedValue(null), + getAllSettings: vi.fn().mockResolvedValue({}), })); vi.mock('./projectSelection.js', () => ({ diff --git a/orchestrator/src/server/services/projectSelection.ts b/orchestrator/src/server/services/projectSelection.ts index a90530b..d78d56b 100644 --- a/orchestrator/src/server/services/projectSelection.ts +++ b/orchestrator/src/server/services/projectSelection.ts @@ -38,8 +38,10 @@ export async function pickProjectIdsForJob(args: { return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount); } - const overrideModel = await getSetting('model'); - const overrideModelProjectSelection = await getSetting('modelProjectSelection'); + const [overrideModel, overrideModelProjectSelection] = await Promise.all([ + getSetting('model'), + getSetting('modelProjectSelection'), + ]); // Precedence: Project-specific override > Global override > Env var > Default const model = overrideModelProjectSelection || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; diff --git a/orchestrator/src/server/services/scorer.ts b/orchestrator/src/server/services/scorer.ts index ea46c79..e6a09e8 100644 --- a/orchestrator/src/server/services/scorer.ts +++ b/orchestrator/src/server/services/scorer.ts @@ -44,8 +44,10 @@ export async function scoreJobSuitability( return mockScore(job); } - const overrideModel = await getSetting('model'); - const overrideModelScorer = await getSetting('modelScorer'); + const [overrideModel, overrideModelScorer] = await Promise.all([ + getSetting('model'), + getSetting('modelScorer'), + ]); // Precedence: Scorer-specific override > Global override > Env var > Default const model = overrideModelScorer || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts new file mode 100644 index 0000000..fd82c7e --- /dev/null +++ b/orchestrator/src/server/services/settings.ts @@ -0,0 +1,146 @@ +import { AppSettings } from '@shared/types.js'; +import * as settingsRepo from '@server/repositories/settings.js'; +import { getEnvSettingsData } from './envSettings.js'; +import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js'; +import { getProfile } from './profile.js'; + +/** + * Get the effective app settings, combining environment variables and database overrides. + */ +export async function getEffectiveSettings(): Promise { + // Parallelize slow operations + const [overrides, profile] = await Promise.all([ + settingsRepo.getAllSettings(), + getProfile(), + ]); + + const envSettings = await getEnvSettingsData(overrides); + + const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; + 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 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 defaultUkvisajobsMaxJobs = 50; + const overrideUkvisajobsMaxJobsRaw = overrides.ukvisajobsMaxJobs; + const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null; + const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs; + + const defaultGradcrackerMaxJobsPerTerm = 50; + const overrideGradcrackerMaxJobsPerTermRaw = overrides.gradcrackerMaxJobsPerTerm; + const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null; + const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm; + + const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer'; + const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean); + const overrideSearchTermsRaw = overrides.searchTerms; + const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null; + const searchTerms = overrideSearchTerms ?? defaultSearchTerms; + + const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK'; + const overrideJobspyLocation = overrides.jobspyLocation ?? null; + const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation; + + const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10); + const overrideJobspyResultsWantedRaw = overrides.jobspyResultsWanted; + const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null; + const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted; + + const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10); + const overrideJobspyHoursOldRaw = overrides.jobspyHoursOld; + const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null; + const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld; + + const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK'; + const overrideJobspyCountryIndeed = overrides.jobspyCountryIndeed ?? null; + const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed; + + const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean); + const overrideJobspySitesRaw = overrides.jobspySites; + const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null; + const jobspySites = overrideJobspySites ?? defaultJobspySites; + + const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1'; + const overrideJobspyLinkedinFetchDescriptionRaw = overrides.jobspyLinkedinFetchDescription; + const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw + ? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1' + : null; + const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription; + + const defaultShowSponsorInfo = true; + const overrideShowSponsorInfoRaw = overrides.showSponsorInfo; + const overrideShowSponsorInfo = overrideShowSponsorInfoRaw + ? overrideShowSponsorInfoRaw === 'true' || overrideShowSponsorInfoRaw === '1' + : null; + const showSponsorInfo = overrideShowSponsorInfo ?? defaultShowSponsorInfo; + + return { + model, + defaultModel, + overrideModel, + modelScorer, + overrideModelScorer, + modelTailoring, + overrideModelTailoring, + modelProjectSelection, + overrideModelProjectSelection, + pipelineWebhookUrl, + defaultPipelineWebhookUrl, + overridePipelineWebhookUrl, + jobCompleteWebhookUrl, + defaultJobCompleteWebhookUrl, + overrideJobCompleteWebhookUrl, + ...resumeProjectsData, + ukvisajobsMaxJobs, + defaultUkvisajobsMaxJobs, + overrideUkvisajobsMaxJobs, + gradcrackerMaxJobsPerTerm, + defaultGradcrackerMaxJobsPerTerm, + overrideGradcrackerMaxJobsPerTerm, + searchTerms, + defaultSearchTerms, + overrideSearchTerms, + jobspyLocation, + defaultJobspyLocation, + overrideJobspyLocation, + jobspyResultsWanted, + defaultJobspyResultsWanted, + overrideJobspyResultsWanted, + jobspyHoursOld, + defaultJobspyHoursOld, + overrideJobspyHoursOld, + jobspyCountryIndeed, + defaultJobspyCountryIndeed, + overrideJobspyCountryIndeed, + jobspySites, + defaultJobspySites, + overrideJobspySites, + jobspyLinkedinFetchDescription, + defaultJobspyLinkedinFetchDescription, + overrideJobspyLinkedinFetchDescription, + showSponsorInfo, + defaultShowSponsorInfo, + overrideShowSponsorInfo, + ...envSettings, + } as AppSettings; +} diff --git a/orchestrator/src/server/services/summary.ts b/orchestrator/src/server/services/summary.ts index 03fde39..d339a78 100644 --- a/orchestrator/src/server/services/summary.ts +++ b/orchestrator/src/server/services/summary.ts @@ -69,8 +69,10 @@ export async function generateTailoring( return { success: false, error: 'API key not configured' }; } - const overrideModel = await getSetting('model'); - const overrideModelTailoring = await getSetting('modelTailoring'); + const [overrideModel, overrideModelTailoring] = await Promise.all([ + getSetting('model'), + getSetting('modelTailoring'), + ]); // Precedence: Tailoring-specific override > Global override > Env var > Default const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini'; const prompt = buildTailoringPrompt(profile, jobDescription); diff --git a/orchestrator/src/shared/settings-schema.ts b/orchestrator/src/shared/settings-schema.ts index a4db7f2..4339eb7 100644 --- a/orchestrator/src/shared/settings-schema.ts +++ b/orchestrator/src/shared/settings-schema.ts @@ -24,6 +24,25 @@ export const updateSettingsSchema = z.object({ jobspySites: z.array(z.string().trim().min(1).max(50)).max(20).nullable().optional(), jobspyLinkedinFetchDescription: z.boolean().nullable().optional(), showSponsorInfo: z.boolean().nullable().optional(), + openrouterApiKey: z.string().trim().max(2000).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(), + webhookSecret: z.string().trim().max(2000).nullable().optional(), + enableBasicAuth: z.boolean().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"], + }); + } + } }); export type UpdateSettingsInput = z.infer; diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index d59c27f..fbafbf6 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -383,4 +383,13 @@ export interface AppSettings { showSponsorInfo: boolean; defaultShowSponsorInfo: boolean; overrideShowSponsorInfo: boolean | null; + openrouterApiKeyHint: string | null; + rxresumeEmail: string | null; + rxresumePasswordHint: string | null; + basicAuthUser: string | null; + basicAuthPasswordHint: string | null; + ukvisajobsEmail: string | null; + ukvisajobsPasswordHint: string | null; + webhookSecretHint: string | null; + basicAuthActive: boolean; }