Merge pull request #18 from DaKheera47/env-to-ui

Env in UI
This commit is contained in:
Shaheer Sarfaraz 2026-01-22 16:57:04 +00:00 committed by GitHub
commit 1ca459ec34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1294 additions and 679 deletions

View File

@ -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<AppSettings> {
return fetchApi<AppSettings>('/settings', {
method: 'PATCH',

View File

@ -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,
})
)
})
})

View File

@ -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,6 +286,16 @@ 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)
@ -245,6 +305,50 @@ export const SettingsPage: React.FC = () => {
? null
: resumeProjectsData
const envPayload: Partial<UpdateSettingsInput> = {}
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}
/>
<PipelineWebhookSection
values={pipelineWebhook}
isLoading={isLoading}
isSaving={isSaving}
/>
<JobCompleteWebhookSection
values={jobCompleteWebhook}
<WebhooksSection
pipelineWebhook={pipelineWebhook}
jobCompleteWebhook={jobCompleteWebhook}
webhookSecretHint={envSettings.private.webhookSecretHint}
isLoading={isLoading}
isSaving={isSaving}
/>
@ -407,6 +514,11 @@ export const SettingsPage: React.FC = () => {
isLoading={isLoading}
isSaving={isSaving}
/>
<EnvironmentSettingsSection
values={envSettings}
isLoading={isLoading}
isSaving={isSaving}
/>
<DangerZoneSection
statusesToClear={statusesToClear}
toggleStatusToClear={toggleStatusToClear}

View File

@ -0,0 +1,71 @@
import { render, screen } from "@testing-library/react"
import { useForm, FormProvider } from "react-hook-form"
import { Accordion } from "@/components/ui/accordion"
import { EnvironmentSettingsSection } from "./EnvironmentSettingsSection"
import { UpdateSettingsInput } from "@shared/settings-schema"
const EnvironmentSettingsHarness = () => {
const methods = useForm<UpdateSettingsInput>({
defaultValues: {
rxresumeEmail: "resume@example.com",
ukvisajobsEmail: "visa@example.com",
basicAuthUser: "admin",
openrouterApiKey: "",
rxresumePassword: "",
ukvisajobsPassword: "",
basicAuthPassword: "",
webhookSecret: "",
enableBasicAuth: true,
}
})
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["environment"]}>
<EnvironmentSettingsSection
values={{
readable: {
rxresumeEmail: "resume@example.com",
ukvisajobsEmail: "visa@example.com",
basicAuthUser: "admin",
},
private: {
openrouterApiKeyHint: "sk-1",
rxresumePasswordHint: null,
ukvisajobsPasswordHint: "pass",
basicAuthPasswordHint: "abcd",
webhookSecretHint: "sec-",
},
basicAuthActive: true,
}}
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("EnvironmentSettingsSection", () => {
it("renders values grouped logically and masks private secrets with hints", () => {
render(<EnvironmentSettingsHarness />)
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()
})
})

View File

@ -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<EnvironmentSettingsSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { register, control, watch, formState: { errors } } = useFormContext<UpdateSettingsInput>()
const { private: privateValues } = values
const isBasicAuthEnabled = watch("enableBasicAuth")
return (
<AccordionItem value="environment" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Environment & Accounts</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-8">
{/* External Services */}
<div className="space-y-4">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">External Services</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="OpenRouter API key"
inputProps={register("openrouterApiKey")}
type="password"
placeholder="Enter new key"
disabled={isLoading || isSaving}
error={errors.openrouterApiKey?.message as string | undefined}
current={formatSecretHint(privateValues.openrouterApiKeyHint)}
/>
</div>
</div>
<Separator />
{/* Service Accounts */}
<div className="space-y-6">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Service Accounts</div>
<div className="space-y-4">
<div className="text-sm font-semibold">RxResume</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={register("rxresumeEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
error={errors.rxresumeEmail?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("rxresumePassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.rxresumePassword?.message as string | undefined}
current={formatSecretHint(privateValues.rxresumePasswordHint)}
/>
</div>
</div>
<div className="space-y-4">
<div className="text-sm font-semibold">UKVisaJobs</div>
<div className="grid gap-4 md:grid-cols-2">
<SettingsInput
label="Email"
inputProps={register("ukvisajobsEmail")}
placeholder="you@example.com"
disabled={isLoading || isSaving}
error={errors.ukvisajobsEmail?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("ukvisajobsPassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.ukvisajobsPassword?.message as string | undefined}
current={formatSecretHint(privateValues.ukvisajobsPasswordHint)}
/>
</div>
</div>
</div>
<Separator />
{/* Security */}
<div className="space-y-4">
<div className="text-sm font-bold uppercase tracking-wider text-muted-foreground">Security</div>
<div className="flex items-start space-x-3">
<Controller
name="enableBasicAuth"
control={control}
render={({ field }) => (
<Checkbox
id="enableBasicAuth"
checked={field.value}
onCheckedChange={field.onChange}
disabled={isLoading || isSaving}
/>
)}
/>
<div className="flex flex-col gap-1.5">
<label
htmlFor="enableBasicAuth"
className="text-sm font-medium leading-none cursor-pointer"
>
Enable basic authentication
</label>
<p className="text-xs text-muted-foreground">
Require a username and password for write operations.
</p>
</div>
</div>
{isBasicAuthEnabled && (
<div className="grid gap-4 md:grid-cols-2 pt-2">
<SettingsInput
label="Username"
inputProps={register("basicAuthUser")}
placeholder="username"
disabled={isLoading || isSaving}
error={errors.basicAuthUser?.message as string | undefined}
/>
<SettingsInput
label="Password"
inputProps={register("basicAuthPassword")}
type="password"
placeholder="Enter new password"
disabled={isLoading || isSaving}
error={errors.basicAuthPassword?.message as string | undefined}
current={formatSecretHint(privateValues.basicAuthPasswordHint)}
/>
</div>
)}
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -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<GradcrackerSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs per search term</div>
<Controller
name="gradcrackerMaxJobsPerTerm"
control={control}
render={({ field }) => (
<Input
<SettingsInput
label="Max jobs per search term"
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? defaultGradcrackerMaxJobsPerTerm}
onChange={(event) => {
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
onChange: (event) => {
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}
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)}
/>
)}
/>
{errors.gradcrackerMaxJobsPerTerm && <p className="text-xs text-destructive">{errors.gradcrackerMaxJobsPerTerm.message}</p>}
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveGradcrackerMaxJobsPerTerm}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>

View File

@ -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<JobCompleteWebhookSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { default: defaultJobCompleteWebhookUrl, effective: effectiveJobCompleteWebhookUrl } = values
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="job-complete-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Job Complete Webhook</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Job completion webhook URL</div>
<Input
{...register("jobCompleteWebhookUrl")}
placeholder={defaultJobCompleteWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
{errors.jobCompleteWebhookUrl && <p className="text-xs text-destructive">{errors.jobCompleteWebhookUrl.message}</p>}
<div className="text-xs text-muted-foreground">
When set, the server sends a POST when you mark a job as applied (includes the job description).
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveJobCompleteWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultJobCompleteWebhookUrl || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -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<JobspySectionProps> = ({
</div>
<div className="grid gap-6 md:grid-cols-2">
<div className="space-y-2">
<div className="text-sm font-medium">Location</div>
<Input
{...register("jobspyLocation")}
<SettingsInput
label="Location"
inputProps={register("jobspyLocation")}
placeholder={location.default || "UK"}
disabled={isLoading || isSaving}
error={errors.jobspyLocation?.message as string | undefined}
helper={'Location to search for jobs (e.g. "UK", "London", "Remote").'}
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
/>
{errors.jobspyLocation && <p className="text-xs text-destructive">{errors.jobspyLocation.message}</p>}
<div className="text-xs text-muted-foreground">
Location to search for jobs (e.g. "UK", "London", "Remote").
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {location.effective || "—"}</span>
<span>Default: {location.default || "—"}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Results Wanted</div>
<Controller
name="jobspyResultsWanted"
control={control}
render={({ field }) => (
<Input
<SettingsInput
label="Results Wanted"
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? resultsWanted.default}
onChange={(event) => {
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? resultsWanted.default,
onChange: (event) => {
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}
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}`}
/>
)}
/>
{errors.jobspyResultsWanted && <p className="text-xs text-destructive">{errors.jobspyResultsWanted.message}</p>}
<div className="text-xs text-muted-foreground">
Number of results to fetch per term per site. Max 1000.
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {resultsWanted.effective}</span>
<span>Default: {resultsWanted.default}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Hours Old</div>
<Controller
name="jobspyHoursOld"
control={control}
render={({ field }) => (
<Input
<SettingsInput
label="Hours Old"
type="number"
inputMode="numeric"
min={1}
max={720}
value={field.value ?? hoursOld.default}
onChange={(event) => {
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 720,
value: field.value ?? hoursOld.default,
onChange: (event) => {
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}
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`}
/>
)}
/>
{errors.jobspyHoursOld && <p className="text-xs text-destructive">{errors.jobspyHoursOld.message}</p>}
<div className="text-xs text-muted-foreground">
Max age of jobs in hours (e.g. 72 for 3 days). Max 720 (30 days).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {hoursOld.effective}h</span>
<span>Default: {hoursOld.default}h</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm font-medium">Indeed Country</div>
<Input
{...register("jobspyCountryIndeed")}
<SettingsInput
label="Indeed Country"
inputProps={register("jobspyCountryIndeed")}
placeholder={countryIndeed.default || "UK"}
disabled={isLoading || isSaving}
error={errors.jobspyCountryIndeed?.message as string | undefined}
helper={'Country domain for Indeed (e.g. "UK" for indeed.co.uk).'}
current={`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
/>
{errors.jobspyCountryIndeed && <p className="text-xs text-destructive">{errors.jobspyCountryIndeed.message}</p>}
<div className="text-xs text-muted-foreground">
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span>Effective: {countryIndeed.effective || "—"}</span>
<span>Default: {countryIndeed.default || "—"}</span>
</div>
</div>
</div>
<Separator />

View File

@ -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<ModelSettingsSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Override model</div>
<Input
{...register("model")}
<SettingsInput
label="Override model"
inputProps={register("model")}
placeholder={defaultModel || "openai/gpt-4o-mini"}
disabled={isLoading || isSaving}
error={errors.model?.message as string | undefined}
helper="Leave blank to use the default from server env (`MODEL`)."
current={effective || "—"}
/>
{errors.model && <p className="text-xs text-destructive">{errors.model.message}</p>}
<div className="text-xs text-muted-foreground">
Leave blank to use the default from server env (`MODEL`).
</div>
</div>
<Separator />
@ -47,44 +44,32 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
<div className="text-sm font-medium">Task-Specific Overrides</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div className="space-y-2">
<div className="text-sm">Scoring Model</div>
<Input
{...register("modelScorer")}
<SettingsInput
label="Scoring Model"
inputProps={register("modelScorer")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelScorer?.message as string | undefined}
current={scorer || effective || "—"}
/>
{errors.modelScorer && <p className="text-xs text-destructive">{errors.modelScorer.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{scorer || effective}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Tailoring Model</div>
<Input
{...register("modelTailoring")}
<SettingsInput
label="Tailoring Model"
inputProps={register("modelTailoring")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelTailoring?.message as string | undefined}
current={tailoring || effective || "—"}
/>
{errors.modelTailoring && <p className="text-xs text-destructive">{errors.modelTailoring.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{tailoring || effective}</span>
</div>
</div>
<div className="space-y-2">
<div className="text-sm">Project Selection Model</div>
<Input
{...register("modelProjectSelection")}
<SettingsInput
label="Project Selection Model"
inputProps={register("modelProjectSelection")}
placeholder={effective || "inherit"}
disabled={isLoading || isSaving}
error={errors.modelProjectSelection?.message as string | undefined}
current={projectSelection || effective || "—"}
/>
{errors.modelProjectSelection && <p className="text-xs text-destructive">{errors.modelProjectSelection.message}</p>}
<div className="text-xs text-muted-foreground">
Effective: <span className="font-mono">{projectSelection || effective}</span>
</div>
</div>
</div>
</div>

View File

@ -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<PipelineWebhookSectionProps> = ({
values,
isLoading,
isSaving,
}) => {
const { default: defaultPipelineWebhookUrl, effective: effectivePipelineWebhookUrl } = values
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="pipeline-webhook" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Pipeline Webhook</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Pipeline status webhook URL</div>
<Input
{...register("pipelineWebhookUrl")}
placeholder={defaultPipelineWebhookUrl || "https://..."}
disabled={isLoading || isSaving}
/>
{errors.pipelineWebhookUrl && <p className="text-xs text-destructive">{errors.pipelineWebhookUrl.message}</p>}
<div className="text-xs text-muted-foreground">
When set, the server sends a POST on pipeline completion/failure. Leave blank to disable.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectivePipelineWebhookUrl || "—"}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default (env)</div>
<div className="break-words font-mono text-xs">{defaultPipelineWebhookUrl || "—"}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -0,0 +1,45 @@
import React from "react"
import { Input } from "@/components/ui/input"
type SettingsInputProps = {
label: string
inputProps: React.InputHTMLAttributes<HTMLInputElement>
placeholder?: string
type?: React.HTMLInputTypeAttribute
disabled?: boolean
error?: string
helper?: string
current?: string | null
}
export const SettingsInput: React.FC<SettingsInputProps> = ({
label,
inputProps,
placeholder,
type = "text",
disabled,
error,
helper,
current,
}) => {
const id = inputProps.id || inputProps.name
return (
<div className="space-y-2">
{label && (
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
)}
<Input {...inputProps} id={id} type={type} placeholder={placeholder} disabled={disabled} />
{error && <p className="text-xs text-destructive">{error}</p>}
{helper && <div className="text-xs text-muted-foreground">{helper}</div>}
{current !== undefined && (
<div className="text-xs text-muted-foreground">
Current: <span className="font-mono">{current}</span>
</div>
)}
</div>
)
}

View File

@ -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<UkvisajobsSectionProps> = ({
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
<div className="space-y-2">
<div className="text-sm font-medium">Max jobs to fetch</div>
<Controller
name="ukvisajobsMaxJobs"
control={control}
render={({ field }) => (
<Input
<SettingsInput
label="Max jobs to fetch"
type="number"
inputMode="numeric"
min={1}
max={1000}
value={field.value ?? defaultUkvisajobsMaxJobs}
onChange={(event) => {
inputProps={{
...field,
inputMode: "numeric",
min: 1,
max: 1000,
value: field.value ?? defaultUkvisajobsMaxJobs,
onChange: (event) => {
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}
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)}
/>
)}
/>
{errors.ukvisajobsMaxJobs && <p className="text-xs text-destructive">{errors.ukvisajobsMaxJobs.message}</p>}
<div className="text-xs text-muted-foreground">
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
</div>
</div>
<Separator />
<div className="grid gap-2 text-sm sm:grid-cols-2">
<div>
<div className="text-xs text-muted-foreground">Effective</div>
<div className="break-words font-mono text-xs">{effectiveUkvisajobsMaxJobs}</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Default</div>
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>

View File

@ -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<UpdateSettingsInput>({
defaultValues: {
pipelineWebhookUrl: "https://pipeline.com",
jobCompleteWebhookUrl: "https://job.com",
webhookSecret: "",
}
})
return (
<FormProvider {...methods}>
<Accordion type="multiple" defaultValue={["webhooks"]}>
<WebhooksSection
pipelineWebhook={{
default: "https://default-p.com",
effective: "https://pipeline.com",
}}
jobCompleteWebhook={{
default: "https://default-j.com",
effective: "https://job.com",
}}
webhookSecretHint="sec-"
isLoading={false}
isSaving={false}
/>
</Accordion>
</FormProvider>
)
}
describe("WebhooksSection", () => {
it("renders both webhook sections and the secret", () => {
render(<WebhooksHarness />)
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()
})
})

View File

@ -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<WebhooksSectionProps> = ({
pipelineWebhook,
jobCompleteWebhook,
webhookSecretHint,
isLoading,
isSaving,
}) => {
const { register, formState: { errors } } = useFormContext<UpdateSettingsInput>()
return (
<AccordionItem value="webhooks" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Webhooks</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-6">
<div className="space-y-4">
<div className="text-sm font-medium">Pipeline Status</div>
<SettingsInput
label="Webhook URL"
inputProps={register("pipelineWebhookUrl")}
placeholder={pipelineWebhook.default || "https://..."}
disabled={isLoading || isSaving}
error={errors.pipelineWebhookUrl?.message as string | undefined}
helper={`When set, the server sends a POST on pipeline completion/failure. Default: ${pipelineWebhook.default || "—"}.`}
current={pipelineWebhook.effective || "—"}
/>
</div>
<Separator />
<div className="space-y-4">
<div className="text-sm font-medium">Job Completion</div>
<div className="space-y-4">
<SettingsInput
label="Webhook URL"
inputProps={register("jobCompleteWebhookUrl")}
placeholder={jobCompleteWebhook.default || "https://..."}
disabled={isLoading || isSaving}
error={errors.jobCompleteWebhookUrl?.message as string | undefined}
helper={`When set, the server sends a POST when you mark a job as applied (includes the job description). Default: ${jobCompleteWebhook.default || "—"}.`}
current={jobCompleteWebhook.effective || "—"}
/>
<SettingsInput
label="Webhook Secret"
inputProps={register("webhookSecret")}
type="password"
placeholder="Enter new secret"
disabled={isLoading || isSaving}
error={errors.webhookSecret?.message as string | undefined}
helper="Secret sent to webhook (Bearer token)"
current={formatSecretHint(webhookSecretHint)}
/>
</div>
</div>
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -22,3 +22,19 @@ export type JobspyValues = {
countryIndeed: EffectiveDefault<string>
linkedinFetchDescription: EffectiveDefault<boolean>
}
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
}

View File

@ -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")

View File

@ -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');
});
});

View File

@ -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,38 +33,37 @@ settingsRouter.get('/', async (_req: Request, res: Response) => {
settingsRouter.patch('/', async (req: Request, res: Response) => {
try {
const input = updateSettingsSchema.parse(req.body);
const promises: Promise<void>[] = [];
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 {
promises.push((async () => {
const rawProfile = await getProfile();
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
@ -203,196 +75,120 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
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 });
}
});

View File

@ -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<void>((resolve) => server.once('listening', () => resolve()));

View File

@ -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,
};
}

View File

@ -4,13 +4,17 @@
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 () => {
const app = createApp();
const PORT = process.env.PORT || 3001;
// Start server
app.listen(PORT, async () => {
console.log(`
@ -31,4 +35,7 @@ app.listen(PORT, async () => {
} catch (error) {
console.warn('⚠️ Failed to initialize visa sponsors service:', error);
}
});
});
}
void startServer();

View File

@ -121,8 +121,11 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): 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<PipelineConfig> = {}): 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<PipelineConfig> = {}): 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<PipelineConfig> = {}): 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<PipelineConfig> = {}): 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<PipelineConfig> = {}): 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({

View File

@ -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', () => ({

View File

@ -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<string | null> {
const [row] = await db.select().from(settings).where(eq(settings.key, key))
return row?.value ?? null
}
export async function getAllSettings(): Promise<Partial<Record<SettingKey, string>>> {
const rows = await db.select().from(settings)
return rows.reduce((acc, row) => {
acc[row.key as SettingKey] = row.value
return acc
}, {} as Partial<Record<SettingKey, string>>)
}
export async function setSetting(key: SettingKey, value: string | null): Promise<void> {
const now = new Date().toISOString()

View File

@ -0,0 +1,118 @@
import * as settingsRepo from '@server/repositories/settings.js';
import { SettingKey } from '@server/repositories/settings.js';
const envDefaults: Record<string, string | undefined> = { ...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<void> {
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<Record<SettingKey, string>>
): Promise<Record<string, string | boolean | number | null>> {
const activeOverrides = overrides || await settingsRepo.getAllSettings();
const readableValues: Record<string, string | boolean | null> = {};
const privateValues: Record<string, string | null> = {};
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,
};

View File

@ -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;

View File

@ -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', () => ({

View File

@ -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', () => ({

View File

@ -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';

View File

@ -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';

View File

@ -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<AppSettings> {
// 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;
}

View File

@ -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);

View File

@ -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<typeof updateSettingsSchema>;

View File

@ -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;
}