commit
1ca459ec34
@ -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',
|
||||
|
||||
@ -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,
|
||||
})
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={field.value ?? defaultGradcrackerMaxJobsPerTerm}
|
||||
onChange={(event) => {
|
||||
<Controller
|
||||
name="gradcrackerMaxJobsPerTerm"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Max jobs per search term"
|
||||
type="number"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
},
|
||||
}}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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")}
|
||||
placeholder={location.default || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{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>
|
||||
<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 || "—"}`}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Results Wanted</div>
|
||||
<Controller
|
||||
name="jobspyResultsWanted"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={field.value ?? resultsWanted.default}
|
||||
onChange={(event) => {
|
||||
<Controller
|
||||
name="jobspyResultsWanted"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Results Wanted"
|
||||
type="number"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
},
|
||||
}}
|
||||
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}`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Hours Old</div>
|
||||
<Controller
|
||||
name="jobspyHoursOld"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={720}
|
||||
value={field.value ?? hoursOld.default}
|
||||
onChange={(event) => {
|
||||
<Controller
|
||||
name="jobspyHoursOld"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Hours Old"
|
||||
type="number"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
},
|
||||
}}
|
||||
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`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Indeed Country</div>
|
||||
<Input
|
||||
{...register("jobspyCountryIndeed")}
|
||||
placeholder={countryIndeed.default || "UK"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{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>
|
||||
<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 || "—"}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@ -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")}
|
||||
placeholder={defaultModel || "openai/gpt-4o-mini"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{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>
|
||||
<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 || "—"}
|
||||
/>
|
||||
|
||||
<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")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{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>
|
||||
<SettingsInput
|
||||
label="Scoring Model"
|
||||
inputProps={register("modelScorer")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelScorer?.message as string | undefined}
|
||||
current={scorer || effective || "—"}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Tailoring Model</div>
|
||||
<Input
|
||||
{...register("modelTailoring")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{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>
|
||||
<SettingsInput
|
||||
label="Tailoring Model"
|
||||
inputProps={register("modelTailoring")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelTailoring?.message as string | undefined}
|
||||
current={tailoring || effective || "—"}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm">Project Selection Model</div>
|
||||
<Input
|
||||
{...register("modelProjectSelection")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
/>
|
||||
{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>
|
||||
<SettingsInput
|
||||
label="Project Selection Model"
|
||||
inputProps={register("modelProjectSelection")}
|
||||
placeholder={effective || "inherit"}
|
||||
disabled={isLoading || isSaving}
|
||||
error={errors.modelProjectSelection?.message as string | undefined}
|
||||
current={projectSelection || effective || "—"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={1000}
|
||||
value={field.value ?? defaultUkvisajobsMaxJobs}
|
||||
onChange={(event) => {
|
||||
<Controller
|
||||
name="ukvisajobsMaxJobs"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<SettingsInput
|
||||
label="Max jobs to fetch"
|
||||
type="number"
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{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>
|
||||
},
|
||||
}}
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<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 {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@ -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()));
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
118
orchestrator/src/server/services/envSettings.ts
Normal file
118
orchestrator/src/server/services/envSettings.ts
Normal 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,
|
||||
};
|
||||
@ -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;
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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', () => ({
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
146
orchestrator/src/server/services/settings.ts
Normal file
146
orchestrator/src/server/services/settings.ts
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user