commit
1ca459ec34
@ -198,6 +198,14 @@ export async function updateSettings(update: {
|
|||||||
jobspySites?: string[] | null
|
jobspySites?: string[] | null
|
||||||
jobspyLinkedinFetchDescription?: boolean | null
|
jobspyLinkedinFetchDescription?: boolean | null
|
||||||
showSponsorInfo?: 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> {
|
}): Promise<AppSettings> {
|
||||||
return fetchApi<AppSettings>('/settings', {
|
return fetchApi<AppSettings>('/settings', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@ -95,6 +95,15 @@ const baseSettings: AppSettings = {
|
|||||||
showSponsorInfo: true,
|
showSponsorInfo: true,
|
||||||
defaultShowSponsorInfo: true,
|
defaultShowSponsorInfo: true,
|
||||||
overrideShowSponsorInfo: null,
|
overrideShowSponsorInfo: null,
|
||||||
|
openrouterApiKeyHint: null,
|
||||||
|
rxresumeEmail: "",
|
||||||
|
rxresumePasswordHint: null,
|
||||||
|
basicAuthUser: "",
|
||||||
|
basicAuthPasswordHint: null,
|
||||||
|
ukvisajobsEmail: "",
|
||||||
|
ukvisajobsPasswordHint: null,
|
||||||
|
webhookSecretHint: null,
|
||||||
|
basicAuthActive: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderPage = () => {
|
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 { resumeProjectsEqual } from "@client/pages/settings/utils"
|
||||||
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
|
import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection"
|
||||||
import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection"
|
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 { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection"
|
||||||
import { JobCompleteWebhookSection } from "@client/pages/settings/components/JobCompleteWebhookSection"
|
|
||||||
import { JobspySection } from "@client/pages/settings/components/JobspySection"
|
import { JobspySection } from "@client/pages/settings/components/JobspySection"
|
||||||
import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection"
|
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 { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection"
|
||||||
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
|
import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection"
|
||||||
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
|
import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection"
|
||||||
@ -41,6 +41,15 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
|||||||
jobspySites: null,
|
jobspySites: null,
|
||||||
jobspyLinkedinFetchDescription: null,
|
jobspyLinkedinFetchDescription: null,
|
||||||
showSponsorInfo: null,
|
showSponsorInfo: null,
|
||||||
|
openrouterApiKey: "",
|
||||||
|
rxresumeEmail: "",
|
||||||
|
rxresumePassword: "",
|
||||||
|
basicAuthUser: "",
|
||||||
|
basicAuthPassword: "",
|
||||||
|
ukvisajobsEmail: "",
|
||||||
|
ukvisajobsPassword: "",
|
||||||
|
webhookSecret: "",
|
||||||
|
enableBasicAuth: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||||
@ -61,6 +70,15 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
|||||||
jobspySites: null,
|
jobspySites: null,
|
||||||
jobspyLinkedinFetchDescription: null,
|
jobspyLinkedinFetchDescription: null,
|
||||||
showSponsorInfo: 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 => ({
|
const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||||
@ -81,6 +99,15 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
|||||||
jobspySites: data.overrideJobspySites,
|
jobspySites: data.overrideJobspySites,
|
||||||
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
|
jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription,
|
||||||
showSponsorInfo: data.overrideShowSponsorInfo,
|
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) => {
|
const normalizeString = (value: string | null | undefined) => {
|
||||||
@ -88,6 +115,12 @@ const normalizeString = (value: string | null | undefined) => {
|
|||||||
return trimmed ? trimmed : null
|
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) => {
|
const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => {
|
||||||
if (!left && !right) return true
|
if (!left && !right) return true
|
||||||
if (!left || !right) return false
|
if (!left || !right) return false
|
||||||
@ -170,7 +203,23 @@ const getDerivedSettings = (settings: AppSettings | null) => {
|
|||||||
effective: settings?.showSponsorInfo ?? true,
|
effective: settings?.showSponsorInfo ?? true,
|
||||||
default: settings?.defaultShowSponsorInfo ?? 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,
|
defaultResumeProjects: settings?.defaultResumeProjects ?? null,
|
||||||
|
|
||||||
profileProjects,
|
profileProjects,
|
||||||
maxProjectsTotal: profileProjects.length,
|
maxProjectsTotal: profileProjects.length,
|
||||||
}
|
}
|
||||||
@ -188,7 +237,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
defaultValues: DEFAULT_FORM_VALUES,
|
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(() => {
|
useEffect(() => {
|
||||||
let isMounted = true
|
let isMounted = true
|
||||||
@ -224,6 +273,7 @@ export const SettingsPage: React.FC = () => {
|
|||||||
searchTerms,
|
searchTerms,
|
||||||
jobspy,
|
jobspy,
|
||||||
display,
|
display,
|
||||||
|
envSettings,
|
||||||
defaultResumeProjects,
|
defaultResumeProjects,
|
||||||
profileProjects,
|
profileProjects,
|
||||||
maxProjectsTotal,
|
maxProjectsTotal,
|
||||||
@ -236,6 +286,16 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
const onSave = async (data: UpdateSettingsInput) => {
|
const onSave = async (data: UpdateSettingsInput) => {
|
||||||
if (!settings) return
|
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 {
|
try {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
|
|
||||||
@ -245,6 +305,50 @@ export const SettingsPage: React.FC = () => {
|
|||||||
? null
|
? null
|
||||||
: resumeProjectsData
|
: 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 = {
|
const payload: UpdateSettingsInput = {
|
||||||
model: normalizeString(data.model),
|
model: normalizeString(data.model),
|
||||||
modelScorer: normalizeString(data.modelScorer),
|
modelScorer: normalizeString(data.modelScorer),
|
||||||
@ -266,8 +370,14 @@ export const SettingsPage: React.FC = () => {
|
|||||||
jobspy.linkedinFetchDescription.default
|
jobspy.linkedinFetchDescription.default
|
||||||
),
|
),
|
||||||
showSponsorInfo: nullIfSame(data.showSponsorInfo, display.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)
|
const updated = await api.updateSettings(payload)
|
||||||
setSettings(updated)
|
setSettings(updated)
|
||||||
reset(mapSettingsToForm(updated))
|
reset(mapSettingsToForm(updated))
|
||||||
@ -365,13 +475,10 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
<PipelineWebhookSection
|
<WebhooksSection
|
||||||
values={pipelineWebhook}
|
pipelineWebhook={pipelineWebhook}
|
||||||
isLoading={isLoading}
|
jobCompleteWebhook={jobCompleteWebhook}
|
||||||
isSaving={isSaving}
|
webhookSecretHint={envSettings.private.webhookSecretHint}
|
||||||
/>
|
|
||||||
<JobCompleteWebhookSection
|
|
||||||
values={jobCompleteWebhook}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
@ -407,6 +514,11 @@ export const SettingsPage: React.FC = () => {
|
|||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isSaving={isSaving}
|
isSaving={isSaving}
|
||||||
/>
|
/>
|
||||||
|
<EnvironmentSettingsSection
|
||||||
|
values={envSettings}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isSaving={isSaving}
|
||||||
|
/>
|
||||||
<DangerZoneSection
|
<DangerZoneSection
|
||||||
statusesToClear={statusesToClear}
|
statusesToClear={statusesToClear}
|
||||||
toggleStatusToClear={toggleStatusToClear}
|
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 { useFormContext, Controller } from "react-hook-form"
|
||||||
|
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
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 { UpdateSettingsInput } from "@shared/settings-schema"
|
||||||
import type { NumericSettingValues } from "@client/pages/settings/types"
|
import type { NumericSettingValues } from "@client/pages/settings/types"
|
||||||
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||||
|
|
||||||
type GradcrackerSectionProps = {
|
type GradcrackerSectionProps = {
|
||||||
values: NumericSettingValues
|
values: NumericSettingValues
|
||||||
@ -28,48 +27,35 @@ export const GradcrackerSection: React.FC<GradcrackerSectionProps> = ({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<Controller
|
||||||
<div className="text-sm font-medium">Max jobs per search term</div>
|
name="gradcrackerMaxJobsPerTerm"
|
||||||
<Controller
|
control={control}
|
||||||
name="gradcrackerMaxJobsPerTerm"
|
render={({ field }) => (
|
||||||
control={control}
|
<SettingsInput
|
||||||
render={({ field }) => (
|
label="Max jobs per search term"
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
inputProps={{
|
||||||
inputMode="numeric"
|
...field,
|
||||||
min={1}
|
inputMode: "numeric",
|
||||||
max={1000}
|
min: 1,
|
||||||
value={field.value ?? defaultGradcrackerMaxJobsPerTerm}
|
max: 1000,
|
||||||
onChange={(event) => {
|
value: field.value ?? defaultGradcrackerMaxJobsPerTerm,
|
||||||
|
onChange: (event) => {
|
||||||
const value = parseInt(event.target.value, 10)
|
const value = parseInt(event.target.value, 10)
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
field.onChange(null)
|
field.onChange(null)
|
||||||
} else {
|
} else {
|
||||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
field.onChange(Math.min(1000, Math.max(1, value)))
|
||||||
}
|
}
|
||||||
}}
|
},
|
||||||
disabled={isLoading || isSaving}
|
}}
|
||||||
/>
|
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.`}
|
||||||
{errors.gradcrackerMaxJobsPerTerm && <p className="text-xs text-destructive">{errors.gradcrackerMaxJobsPerTerm.message}</p>}
|
current={String(effectiveGradcrackerMaxJobsPerTerm)}
|
||||||
<div className="text-xs text-muted-foreground">
|
/>
|
||||||
Maximum number of jobs to fetch for EACH search term from Gradcracker. Range: 1-1000.
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Effective</div>
|
|
||||||
<div className="break-words font-mono text-xs">{effectiveGradcrackerMaxJobsPerTerm}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Default</div>
|
|
||||||
<div className="break-words font-mono text-xs font-semibold">{defaultGradcrackerMaxJobsPerTerm}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</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 { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||||
import type { JobspyValues } from "@client/pages/settings/types"
|
import type { JobspyValues } from "@client/pages/settings/types"
|
||||||
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||||
|
|
||||||
type JobspySectionProps = {
|
type JobspySectionProps = {
|
||||||
values: JobspyValues
|
values: JobspyValues
|
||||||
@ -99,107 +99,85 @@ export const JobspySection: React.FC<JobspySectionProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<SettingsInput
|
||||||
<div className="text-sm font-medium">Location</div>
|
label="Location"
|
||||||
<Input
|
inputProps={register("jobspyLocation")}
|
||||||
{...register("jobspyLocation")}
|
placeholder={location.default || "UK"}
|
||||||
placeholder={location.default || "UK"}
|
disabled={isLoading || isSaving}
|
||||||
disabled={isLoading || isSaving}
|
error={errors.jobspyLocation?.message as string | undefined}
|
||||||
/>
|
helper={'Location to search for jobs (e.g. "UK", "London", "Remote").'}
|
||||||
{errors.jobspyLocation && <p className="text-xs text-destructive">{errors.jobspyLocation.message}</p>}
|
current={`Effective: ${location.effective || "—"} | Default: ${location.default || "—"}`}
|
||||||
<div className="text-xs text-muted-foreground">
|
/>
|
||||||
Location to search for jobs (e.g. "UK", "London", "Remote").
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Effective: {location.effective || "—"}</span>
|
|
||||||
<span>Default: {location.default || "—"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Controller
|
||||||
<div className="text-sm font-medium">Results Wanted</div>
|
name="jobspyResultsWanted"
|
||||||
<Controller
|
control={control}
|
||||||
name="jobspyResultsWanted"
|
render={({ field }) => (
|
||||||
control={control}
|
<SettingsInput
|
||||||
render={({ field }) => (
|
label="Results Wanted"
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
inputProps={{
|
||||||
inputMode="numeric"
|
...field,
|
||||||
min={1}
|
inputMode: "numeric",
|
||||||
max={1000}
|
min: 1,
|
||||||
value={field.value ?? resultsWanted.default}
|
max: 1000,
|
||||||
onChange={(event) => {
|
value: field.value ?? resultsWanted.default,
|
||||||
|
onChange: (event) => {
|
||||||
const value = parseInt(event.target.value, 10)
|
const value = parseInt(event.target.value, 10)
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
field.onChange(null)
|
field.onChange(null)
|
||||||
} else {
|
} else {
|
||||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
field.onChange(Math.min(1000, Math.max(1, value)))
|
||||||
}
|
}
|
||||||
}}
|
},
|
||||||
disabled={isLoading || isSaving}
|
}}
|
||||||
/>
|
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.`}
|
||||||
{errors.jobspyResultsWanted && <p className="text-xs text-destructive">{errors.jobspyResultsWanted.message}</p>}
|
current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`}
|
||||||
<div className="text-xs text-muted-foreground">
|
/>
|
||||||
Number of results to fetch per term per site. Max 1000.
|
)}
|
||||||
</div>
|
/>
|
||||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Effective: {resultsWanted.effective}</span>
|
|
||||||
<span>Default: {resultsWanted.default}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<Controller
|
||||||
<div className="text-sm font-medium">Hours Old</div>
|
name="jobspyHoursOld"
|
||||||
<Controller
|
control={control}
|
||||||
name="jobspyHoursOld"
|
render={({ field }) => (
|
||||||
control={control}
|
<SettingsInput
|
||||||
render={({ field }) => (
|
label="Hours Old"
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
inputProps={{
|
||||||
inputMode="numeric"
|
...field,
|
||||||
min={1}
|
inputMode: "numeric",
|
||||||
max={720}
|
min: 1,
|
||||||
value={field.value ?? hoursOld.default}
|
max: 720,
|
||||||
onChange={(event) => {
|
value: field.value ?? hoursOld.default,
|
||||||
|
onChange: (event) => {
|
||||||
const value = parseInt(event.target.value, 10)
|
const value = parseInt(event.target.value, 10)
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
field.onChange(null)
|
field.onChange(null)
|
||||||
} else {
|
} else {
|
||||||
field.onChange(Math.min(720, Math.max(1, value)))
|
field.onChange(Math.min(720, Math.max(1, value)))
|
||||||
}
|
}
|
||||||
}}
|
},
|
||||||
disabled={isLoading || isSaving}
|
}}
|
||||||
/>
|
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.`}
|
||||||
{errors.jobspyHoursOld && <p className="text-xs text-destructive">{errors.jobspyHoursOld.message}</p>}
|
current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`}
|
||||||
<div className="text-xs text-muted-foreground">
|
/>
|
||||||
Max age of jobs in hours (e.g. 72 for 3 days). Max 720 (30 days).
|
)}
|
||||||
</div>
|
/>
|
||||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Effective: {hoursOld.effective}h</span>
|
|
||||||
<span>Default: {hoursOld.default}h</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SettingsInput
|
||||||
<div className="text-sm font-medium">Indeed Country</div>
|
label="Indeed Country"
|
||||||
<Input
|
inputProps={register("jobspyCountryIndeed")}
|
||||||
{...register("jobspyCountryIndeed")}
|
placeholder={countryIndeed.default || "UK"}
|
||||||
placeholder={countryIndeed.default || "UK"}
|
disabled={isLoading || isSaving}
|
||||||
disabled={isLoading || isSaving}
|
error={errors.jobspyCountryIndeed?.message as string | undefined}
|
||||||
/>
|
helper={'Country domain for Indeed (e.g. "UK" for indeed.co.uk).'}
|
||||||
{errors.jobspyCountryIndeed && <p className="text-xs text-destructive">{errors.jobspyCountryIndeed.message}</p>}
|
current={`Effective: ${countryIndeed.effective || "—"} | Default: ${countryIndeed.default || "—"}`}
|
||||||
<div className="text-xs text-muted-foreground">
|
/>
|
||||||
Country domain for Indeed (e.g. "UK" for indeed.co.uk).
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 text-xs text-muted-foreground">
|
|
||||||
<span>Effective: {countryIndeed.effective || "—"}</span>
|
|
||||||
<span>Default: {countryIndeed.default || "—"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|||||||
@ -2,10 +2,10 @@ import React from "react"
|
|||||||
import { useFormContext } from "react-hook-form"
|
import { useFormContext } from "react-hook-form"
|
||||||
|
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||||
import { Input } from "@/components/ui/input"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
import { Separator } from "@/components/ui/separator"
|
||||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||||
import type { ModelValues } from "@client/pages/settings/types"
|
import type { ModelValues } from "@client/pages/settings/types"
|
||||||
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||||
|
|
||||||
type ModelSettingsSectionProps = {
|
type ModelSettingsSectionProps = {
|
||||||
values: ModelValues
|
values: ModelValues
|
||||||
@ -28,18 +28,15 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<SettingsInput
|
||||||
<div className="text-sm font-medium">Override model</div>
|
label="Override model"
|
||||||
<Input
|
inputProps={register("model")}
|
||||||
{...register("model")}
|
placeholder={defaultModel || "openai/gpt-4o-mini"}
|
||||||
placeholder={defaultModel || "openai/gpt-4o-mini"}
|
disabled={isLoading || isSaving}
|
||||||
disabled={isLoading || isSaving}
|
error={errors.model?.message as string | undefined}
|
||||||
/>
|
helper="Leave blank to use the default from server env (`MODEL`)."
|
||||||
{errors.model && <p className="text-xs text-destructive">{errors.model.message}</p>}
|
current={effective || "—"}
|
||||||
<div className="text-xs text-muted-foreground">
|
/>
|
||||||
Leave blank to use the default from server env (`MODEL`).
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
@ -47,44 +44,32 @@ export const ModelSettingsSection: React.FC<ModelSettingsSectionProps> = ({
|
|||||||
<div className="text-sm font-medium">Task-Specific Overrides</div>
|
<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="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
<div className="space-y-2">
|
<SettingsInput
|
||||||
<div className="text-sm">Scoring Model</div>
|
label="Scoring Model"
|
||||||
<Input
|
inputProps={register("modelScorer")}
|
||||||
{...register("modelScorer")}
|
placeholder={effective || "inherit"}
|
||||||
placeholder={effective || "inherit"}
|
disabled={isLoading || isSaving}
|
||||||
disabled={isLoading || isSaving}
|
error={errors.modelScorer?.message as string | undefined}
|
||||||
/>
|
current={scorer || effective || "—"}
|
||||||
{errors.modelScorer && <p className="text-xs text-destructive">{errors.modelScorer.message}</p>}
|
/>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Effective: <span className="font-mono">{scorer || effective}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SettingsInput
|
||||||
<div className="text-sm">Tailoring Model</div>
|
label="Tailoring Model"
|
||||||
<Input
|
inputProps={register("modelTailoring")}
|
||||||
{...register("modelTailoring")}
|
placeholder={effective || "inherit"}
|
||||||
placeholder={effective || "inherit"}
|
disabled={isLoading || isSaving}
|
||||||
disabled={isLoading || isSaving}
|
error={errors.modelTailoring?.message as string | undefined}
|
||||||
/>
|
current={tailoring || effective || "—"}
|
||||||
{errors.modelTailoring && <p className="text-xs text-destructive">{errors.modelTailoring.message}</p>}
|
/>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Effective: <span className="font-mono">{tailoring || effective}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<SettingsInput
|
||||||
<div className="text-sm">Project Selection Model</div>
|
label="Project Selection Model"
|
||||||
<Input
|
inputProps={register("modelProjectSelection")}
|
||||||
{...register("modelProjectSelection")}
|
placeholder={effective || "inherit"}
|
||||||
placeholder={effective || "inherit"}
|
disabled={isLoading || isSaving}
|
||||||
disabled={isLoading || isSaving}
|
error={errors.modelProjectSelection?.message as string | undefined}
|
||||||
/>
|
current={projectSelection || effective || "—"}
|
||||||
{errors.modelProjectSelection && <p className="text-xs text-destructive">{errors.modelProjectSelection.message}</p>}
|
/>
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
Effective: <span className="font-mono">{projectSelection || effective}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</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 { useFormContext, Controller } from "react-hook-form"
|
||||||
|
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
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 { UpdateSettingsInput } from "@shared/settings-schema"
|
||||||
import type { NumericSettingValues } from "@client/pages/settings/types"
|
import type { NumericSettingValues } from "@client/pages/settings/types"
|
||||||
|
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||||
|
|
||||||
type UkvisajobsSectionProps = {
|
type UkvisajobsSectionProps = {
|
||||||
values: NumericSettingValues
|
values: NumericSettingValues
|
||||||
@ -28,48 +27,35 @@ export const UkvisajobsSection: React.FC<UkvisajobsSectionProps> = ({
|
|||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="pb-4">
|
<AccordionContent className="pb-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<Controller
|
||||||
<div className="text-sm font-medium">Max jobs to fetch</div>
|
name="ukvisajobsMaxJobs"
|
||||||
<Controller
|
control={control}
|
||||||
name="ukvisajobsMaxJobs"
|
render={({ field }) => (
|
||||||
control={control}
|
<SettingsInput
|
||||||
render={({ field }) => (
|
label="Max jobs to fetch"
|
||||||
<Input
|
type="number"
|
||||||
type="number"
|
inputProps={{
|
||||||
inputMode="numeric"
|
...field,
|
||||||
min={1}
|
inputMode: "numeric",
|
||||||
max={1000}
|
min: 1,
|
||||||
value={field.value ?? defaultUkvisajobsMaxJobs}
|
max: 1000,
|
||||||
onChange={(event) => {
|
value: field.value ?? defaultUkvisajobsMaxJobs,
|
||||||
|
onChange: (event) => {
|
||||||
const value = parseInt(event.target.value, 10)
|
const value = parseInt(event.target.value, 10)
|
||||||
if (Number.isNaN(value)) {
|
if (Number.isNaN(value)) {
|
||||||
field.onChange(null)
|
field.onChange(null)
|
||||||
} else {
|
} else {
|
||||||
field.onChange(Math.min(1000, Math.max(1, value)))
|
field.onChange(Math.min(1000, Math.max(1, value)))
|
||||||
}
|
}
|
||||||
}}
|
},
|
||||||
disabled={isLoading || isSaving}
|
}}
|
||||||
/>
|
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.`}
|
||||||
{errors.ukvisajobsMaxJobs && <p className="text-xs text-destructive">{errors.ukvisajobsMaxJobs.message}</p>}
|
current={String(effectiveUkvisajobsMaxJobs)}
|
||||||
<div className="text-xs text-muted-foreground">
|
/>
|
||||||
Maximum number of jobs to fetch from UKVisaJobs per pipeline run. Range: 1-1000.
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
<div className="grid gap-2 text-sm sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Effective</div>
|
|
||||||
<div className="break-words font-mono text-xs">{effectiveUkvisajobsMaxJobs}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">Default</div>
|
|
||||||
<div className="break-words font-mono text-xs font-semibold">{defaultUkvisajobsMaxJobs}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</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>
|
countryIndeed: EffectiveDefault<string>
|
||||||
linkedinFetchDescription: EffectiveDefault<boolean>
|
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)
|
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;
|
let tempDir: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
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 () => {
|
afterEach(async () => {
|
||||||
@ -22,6 +27,9 @@ describe.sequential('Settings API routes', () => {
|
|||||||
expect(body.success).toBe(true);
|
expect(body.success).toBe(true);
|
||||||
expect(body.data.defaultModel).toBe('test-model');
|
expect(body.data.defaultModel).toBe('test-model');
|
||||||
expect(Array.isArray(body.data.searchTerms)).toBe(true);
|
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 () => {
|
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`, {
|
const patchRes = await fetch(`${baseUrl}/api/settings`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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();
|
const patchBody = await patchRes.json();
|
||||||
expect(patchBody.success).toBe(true);
|
expect(patchBody.success).toBe(true);
|
||||||
expect(patchBody.data.searchTerms).toEqual(['engineer']);
|
expect(patchBody.data.searchTerms).toEqual(['engineer']);
|
||||||
expect(patchBody.data.overrideSearchTerms).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 { Router, Request, Response } from 'express';
|
||||||
import { updateSettingsSchema } from '@shared/settings-schema.js';
|
import { updateSettingsSchema } from '@shared/settings-schema.js';
|
||||||
import * as settingsRepo from '@server/repositories/settings.js';
|
import * as settingsRepo from '@server/repositories/settings.js';
|
||||||
|
import {
|
||||||
|
applyEnvValue,
|
||||||
|
normalizeEnvInput,
|
||||||
|
} from '@server/services/envSettings.js';
|
||||||
import {
|
import {
|
||||||
extractProjectsFromProfile,
|
extractProjectsFromProfile,
|
||||||
normalizeResumeProjectsSettings,
|
normalizeResumeProjectsSettings,
|
||||||
resolveResumeProjectsSettings,
|
|
||||||
} from '@server/services/resumeProjects.js';
|
} from '@server/services/resumeProjects.js';
|
||||||
import { getProfile } from '@server/services/profile.js';
|
import { getProfile } from '@server/services/profile.js';
|
||||||
|
import { getEffectiveSettings } from '@server/services/settings.js';
|
||||||
|
|
||||||
export const settingsRouter = Router();
|
export const settingsRouter = Router();
|
||||||
|
|
||||||
@ -15,139 +19,8 @@ export const settingsRouter = Router();
|
|||||||
*/
|
*/
|
||||||
settingsRouter.get('/', async (_req: Request, res: Response) => {
|
settingsRouter.get('/', async (_req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const overrideModel = await settingsRepo.getSetting('model');
|
const data = await getEffectiveSettings();
|
||||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
res.json({ success: true, data });
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
res.status(500).json({ success: false, error: message });
|
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) => {
|
settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
const input = updateSettingsSchema.parse(req.body);
|
const input = updateSettingsSchema.parse(req.body);
|
||||||
|
const promises: Promise<void>[] = [];
|
||||||
|
|
||||||
if ('model' in input) {
|
if ('model' in input) {
|
||||||
const model = input.model ?? null;
|
promises.push(settingsRepo.setSetting('model', input.model ?? null));
|
||||||
await settingsRepo.setSetting('model', model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('modelScorer' in input) {
|
if ('modelScorer' in input) {
|
||||||
await settingsRepo.setSetting('modelScorer', input.modelScorer ?? null);
|
promises.push(settingsRepo.setSetting('modelScorer', input.modelScorer ?? null));
|
||||||
}
|
}
|
||||||
if ('modelTailoring' in input) {
|
if ('modelTailoring' in input) {
|
||||||
await settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null);
|
promises.push(settingsRepo.setSetting('modelTailoring', input.modelTailoring ?? null));
|
||||||
}
|
}
|
||||||
if ('modelProjectSelection' in input) {
|
if ('modelProjectSelection' in input) {
|
||||||
await settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null);
|
promises.push(settingsRepo.setSetting('modelProjectSelection', input.modelProjectSelection ?? null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('pipelineWebhookUrl' in input) {
|
if ('pipelineWebhookUrl' in input) {
|
||||||
const pipelineWebhookUrl = input.pipelineWebhookUrl ?? null;
|
promises.push(settingsRepo.setSetting('pipelineWebhookUrl', input.pipelineWebhookUrl ?? null));
|
||||||
await settingsRepo.setSetting('pipelineWebhookUrl', pipelineWebhookUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('jobCompleteWebhookUrl' in input) {
|
if ('jobCompleteWebhookUrl' in input) {
|
||||||
const webhookUrl = input.jobCompleteWebhookUrl ?? null;
|
promises.push(settingsRepo.setSetting('jobCompleteWebhookUrl', input.jobCompleteWebhookUrl ?? null));
|
||||||
await settingsRepo.setSetting('jobCompleteWebhookUrl', webhookUrl);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('resumeProjects' in input) {
|
if ('resumeProjects' in input) {
|
||||||
const resumeProjects = input.resumeProjects ?? null;
|
const resumeProjects = input.resumeProjects ?? null;
|
||||||
|
|
||||||
if (resumeProjects === null) {
|
if (resumeProjects === null) {
|
||||||
await settingsRepo.setSetting('resumeProjects', null);
|
promises.push(settingsRepo.setSetting('resumeProjects', null));
|
||||||
} else {
|
} else {
|
||||||
const rawProfile = await getProfile();
|
promises.push((async () => {
|
||||||
|
const rawProfile = await getProfile();
|
||||||
|
|
||||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||||
}
|
}
|
||||||
|
|
||||||
const profile = rawProfile as Record<string, unknown>;
|
const profile = rawProfile as Record<string, unknown>;
|
||||||
const { catalog } = extractProjectsFromProfile(profile);
|
const { catalog } = extractProjectsFromProfile(profile);
|
||||||
const allowed = new Set(catalog.map((p) => p.id));
|
const allowed = new Set(catalog.map((p) => p.id));
|
||||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||||
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
|
await settingsRepo.setSetting('resumeProjects', JSON.stringify(normalized));
|
||||||
|
})());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('ukvisajobsMaxJobs' in input) {
|
if ('ukvisajobsMaxJobs' in input) {
|
||||||
const ukvisajobsMaxJobs = input.ukvisajobsMaxJobs ?? null;
|
const val = input.ukvisajobsMaxJobs ?? null;
|
||||||
await settingsRepo.setSetting('ukvisajobsMaxJobs', ukvisajobsMaxJobs !== null ? String(ukvisajobsMaxJobs) : null);
|
promises.push(settingsRepo.setSetting('ukvisajobsMaxJobs', val !== null ? String(val) : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('gradcrackerMaxJobsPerTerm' in input) {
|
if ('gradcrackerMaxJobsPerTerm' in input) {
|
||||||
const gradcrackerMaxJobsPerTerm = input.gradcrackerMaxJobsPerTerm ?? null;
|
const val = input.gradcrackerMaxJobsPerTerm ?? null;
|
||||||
await settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', gradcrackerMaxJobsPerTerm !== null ? String(gradcrackerMaxJobsPerTerm) : null);
|
promises.push(settingsRepo.setSetting('gradcrackerMaxJobsPerTerm', val !== null ? String(val) : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('searchTerms' in input) {
|
if ('searchTerms' in input) {
|
||||||
const searchTerms = input.searchTerms ?? null;
|
const val = input.searchTerms ?? null;
|
||||||
await settingsRepo.setSetting('searchTerms', searchTerms !== null ? JSON.stringify(searchTerms) : null);
|
promises.push(settingsRepo.setSetting('searchTerms', val !== null ? JSON.stringify(val) : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('jobspyLocation' in input) {
|
if ('jobspyLocation' in input) {
|
||||||
const value = input.jobspyLocation ?? null;
|
promises.push(settingsRepo.setSetting('jobspyLocation', input.jobspyLocation ?? null));
|
||||||
await settingsRepo.setSetting('jobspyLocation', value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('jobspyResultsWanted' in input) {
|
if ('jobspyResultsWanted' in input) {
|
||||||
const value = input.jobspyResultsWanted ?? null;
|
const val = input.jobspyResultsWanted ?? null;
|
||||||
await settingsRepo.setSetting('jobspyResultsWanted', value !== null ? String(value) : null);
|
promises.push(settingsRepo.setSetting('jobspyResultsWanted', val !== null ? String(val) : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('jobspyHoursOld' in input) {
|
if ('jobspyHoursOld' in input) {
|
||||||
const value = input.jobspyHoursOld ?? null;
|
const val = input.jobspyHoursOld ?? null;
|
||||||
await settingsRepo.setSetting('jobspyHoursOld', value !== null ? String(value) : null);
|
promises.push(settingsRepo.setSetting('jobspyHoursOld', val !== null ? String(val) : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('jobspyCountryIndeed' in input) {
|
if ('jobspyCountryIndeed' in input) {
|
||||||
const value = input.jobspyCountryIndeed ?? null;
|
promises.push(settingsRepo.setSetting('jobspyCountryIndeed', input.jobspyCountryIndeed ?? null));
|
||||||
await settingsRepo.setSetting('jobspyCountryIndeed', value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('jobspySites' in input) {
|
if ('jobspySites' in input) {
|
||||||
const value = input.jobspySites ?? null;
|
const val = input.jobspySites ?? null;
|
||||||
await settingsRepo.setSetting('jobspySites', value !== null ? JSON.stringify(value) : null);
|
promises.push(settingsRepo.setSetting('jobspySites', val !== null ? JSON.stringify(val) : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('jobspyLinkedinFetchDescription' in input) {
|
if ('jobspyLinkedinFetchDescription' in input) {
|
||||||
const value = input.jobspyLinkedinFetchDescription ?? null;
|
const val = input.jobspyLinkedinFetchDescription ?? null;
|
||||||
await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null);
|
promises.push(settingsRepo.setSetting('jobspyLinkedinFetchDescription', val !== null ? (val ? '1' : '0') : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
if ('showSponsorInfo' in input) {
|
if ('showSponsorInfo' in input) {
|
||||||
const value = input.showSponsorInfo ?? null;
|
const val = input.showSponsorInfo ?? null;
|
||||||
await settingsRepo.setSetting('showSponsorInfo', value !== null ? (value ? '1' : '0') : null);
|
promises.push(settingsRepo.setSetting('showSponsorInfo', val !== null ? (val ? '1' : '0') : null));
|
||||||
}
|
}
|
||||||
|
|
||||||
const overrideModel = await settingsRepo.getSetting('model');
|
if ('openrouterApiKey' in input) {
|
||||||
const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini';
|
const value = normalizeEnvInput(input.openrouterApiKey);
|
||||||
const model = overrideModel || defaultModel;
|
promises.push(settingsRepo.setSetting('openrouterApiKey', value).then(() => {
|
||||||
|
applyEnvValue('OPENROUTER_API_KEY', value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const overrideModelScorer = await settingsRepo.getSetting('modelScorer');
|
if ('rxresumeEmail' in input) {
|
||||||
const modelScorer = overrideModelScorer || model;
|
const value = normalizeEnvInput(input.rxresumeEmail);
|
||||||
|
promises.push(settingsRepo.setSetting('rxresumeEmail', value).then(() => {
|
||||||
|
applyEnvValue('RXRESUME_EMAIL', value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring');
|
if ('rxresumePassword' in input) {
|
||||||
const modelTailoring = overrideModelTailoring || model;
|
const value = normalizeEnvInput(input.rxresumePassword);
|
||||||
|
promises.push(settingsRepo.setSetting('rxresumePassword', value).then(() => {
|
||||||
|
applyEnvValue('RXRESUME_PASSWORD', value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection');
|
if ('basicAuthUser' in input) {
|
||||||
const modelProjectSelection = overrideModelProjectSelection || model;
|
const value = normalizeEnvInput(input.basicAuthUser);
|
||||||
|
promises.push(settingsRepo.setSetting('basicAuthUser', value).then(() => {
|
||||||
|
applyEnvValue('BASIC_AUTH_USER', value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl');
|
if ('basicAuthPassword' in input) {
|
||||||
const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || '';
|
const value = normalizeEnvInput(input.basicAuthPassword);
|
||||||
const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl;
|
promises.push(settingsRepo.setSetting('basicAuthPassword', value).then(() => {
|
||||||
|
applyEnvValue('BASIC_AUTH_PASSWORD', value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl');
|
if ('ukvisajobsEmail' in input) {
|
||||||
const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || '';
|
const value = normalizeEnvInput(input.ukvisajobsEmail);
|
||||||
const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl;
|
promises.push(settingsRepo.setSetting('ukvisajobsEmail', value).then(() => {
|
||||||
|
applyEnvValue('UKVISAJOBS_EMAIL', value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const profile = await getProfile();
|
if ('ukvisajobsPassword' in input) {
|
||||||
const { catalog } = extractProjectsFromProfile(profile);
|
const value = normalizeEnvInput(input.ukvisajobsPassword);
|
||||||
const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects');
|
promises.push(settingsRepo.setSetting('ukvisajobsPassword', value).then(() => {
|
||||||
const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw });
|
applyEnvValue('UKVISAJOBS_PASSWORD', value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs');
|
if ('webhookSecret' in input) {
|
||||||
const defaultUkvisajobsMaxJobs = 50;
|
const value = normalizeEnvInput(input.webhookSecret);
|
||||||
const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null;
|
promises.push(settingsRepo.setSetting('webhookSecret', value).then(() => {
|
||||||
const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs;
|
applyEnvValue('WEBHOOK_SECRET', value);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
|
await Promise.all(promises);
|
||||||
const defaultGradcrackerMaxJobsPerTerm = 50;
|
|
||||||
const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null;
|
|
||||||
const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm;
|
|
||||||
|
|
||||||
// Search terms - stored as JSON array, default from env var (pipe-separated)
|
const data = await getEffectiveSettings();
|
||||||
const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms');
|
res.json({ success: true, data });
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown 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 });
|
res.status(400).json({ success: false, error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -86,11 +86,14 @@ export async function startServer(options?: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await import('../../db/migrate.js');
|
await import('../../db/migrate.js');
|
||||||
|
const { applyStoredEnvOverrides } = await import('../../services/envSettings.js');
|
||||||
const { createApp } = await import('../../app.js');
|
const { createApp } = await import('../../app.js');
|
||||||
const { closeDb } = await import('../../db/index.js');
|
const { closeDb } = await import('../../db/index.js');
|
||||||
const { getPipelineStatus } = await import('../../pipeline/index.js');
|
const { getPipelineStatus } = await import('../../pipeline/index.js');
|
||||||
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false });
|
vi.mocked(getPipelineStatus).mockReturnValue({ isRunning: false });
|
||||||
|
|
||||||
|
await applyStoredEnvOverrides();
|
||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const server = app.listen(0);
|
const server = app.listen(0);
|
||||||
await new Promise<void>((resolve) => server.once('listening', () => resolve()));
|
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));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
function createBasicAuthGuard() {
|
function createBasicAuthGuard() {
|
||||||
const BASIC_AUTH_USER = process.env.BASIC_AUTH_USER || '';
|
function getAuthConfig() {
|
||||||
const BASIC_AUTH_PASSWORD = process.env.BASIC_AUTH_PASSWORD || '';
|
const user = process.env.BASIC_AUTH_USER || '';
|
||||||
const basicAuthEnabled = BASIC_AUTH_USER.length > 0 && BASIC_AUTH_PASSWORD.length > 0;
|
const pass = process.env.BASIC_AUTH_PASSWORD || '';
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
pass,
|
||||||
|
enabled: user.length > 0 && pass.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function isAuthorized(req: express.Request): boolean {
|
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 || '';
|
const authHeader = req.headers.authorization || '';
|
||||||
if (!authHeader.startsWith('Basic ')) return false;
|
if (!authHeader.startsWith('Basic ')) return false;
|
||||||
const encoded = authHeader.slice('Basic '.length).trim();
|
const encoded = authHeader.slice('Basic '.length).trim();
|
||||||
@ -32,7 +39,7 @@ function createBasicAuthGuard() {
|
|||||||
if (separatorIndex === -1) return false;
|
if (separatorIndex === -1) return false;
|
||||||
const user = decoded.slice(0, separatorIndex);
|
const user = decoded.slice(0, separatorIndex);
|
||||||
const pass = decoded.slice(separatorIndex + 1);
|
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 {
|
function isPublicReadOnlyRoute(method: string, path: string): boolean {
|
||||||
@ -48,7 +55,8 @@ function createBasicAuthGuard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const middleware = (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
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();
|
if (isAuthorized(req)) return next();
|
||||||
res.setHeader('WWW-Authenticate', 'Basic realm="Job Ops"');
|
res.setHeader('WWW-Authenticate', 'Basic realm="Job Ops"');
|
||||||
res.status(401).send('Authentication required');
|
res.status(401).send('Authentication required');
|
||||||
@ -57,7 +65,7 @@ function createBasicAuthGuard() {
|
|||||||
return {
|
return {
|
||||||
middleware,
|
middleware,
|
||||||
isAuthorized,
|
isAuthorized,
|
||||||
basicAuthEnabled,
|
basicAuthEnabled: getAuthConfig().enabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,14 +4,18 @@
|
|||||||
|
|
||||||
import './config/env.js';
|
import './config/env.js';
|
||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
|
import { applyStoredEnvOverrides } from './services/envSettings.js';
|
||||||
import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js';
|
import { initialize as initializeVisaSponsors } from './services/visa-sponsors/index.js';
|
||||||
|
|
||||||
const app = createApp();
|
async function startServer() {
|
||||||
const PORT = process.env.PORT || 3001;
|
await applyStoredEnvOverrides();
|
||||||
|
|
||||||
// Start server
|
const app = createApp();
|
||||||
app.listen(PORT, async () => {
|
const PORT = process.env.PORT || 3001;
|
||||||
console.log(`
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
console.log(`
|
||||||
╔═══════════════════════════════════════════════════════════╗
|
╔═══════════════════════════════════════════════════════════╗
|
||||||
║ ║
|
║ ║
|
||||||
║ 🚀 Job Ops Orchestrator ║
|
║ 🚀 Job Ops Orchestrator ║
|
||||||
@ -25,10 +29,13 @@ app.listen(PORT, async () => {
|
|||||||
╚═══════════════════════════════════════════════════════════╝
|
╚═══════════════════════════════════════════════════════════╝
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Initialize visa sponsors service (downloads data if needed, starts scheduler)
|
// Initialize visa sponsors service (downloads data if needed, starts scheduler)
|
||||||
try {
|
try {
|
||||||
await initializeVisaSponsors();
|
await initializeVisaSponsors();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('⚠️ Failed to initialize visa sponsors service:', 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 discoveredJobs: CreateJobInput[] = [];
|
||||||
const sourceErrors: string[] = [];
|
const sourceErrors: string[] = [];
|
||||||
|
|
||||||
|
// Read all settings at once to avoid sequential DB calls
|
||||||
|
const settings = await settingsRepo.getAllSettings();
|
||||||
|
|
||||||
// Read search terms setting
|
// Read search terms setting
|
||||||
const searchTermsSetting = await settingsRepo.getSetting('searchTerms');
|
const searchTermsSetting = settings.searchTerms;
|
||||||
let searchTerms: string[] = [];
|
let searchTerms: string[] = [];
|
||||||
|
|
||||||
if (searchTermsSetting) {
|
if (searchTermsSetting) {
|
||||||
@ -139,7 +142,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Apply setting override for JobSpy sites
|
// Apply setting override for JobSpy sites
|
||||||
const jobspySitesSettingRaw = await settingsRepo.getSetting('jobspySites');
|
const jobspySitesSettingRaw = settings.jobspySites;
|
||||||
if (jobspySitesSettingRaw) {
|
if (jobspySitesSettingRaw) {
|
||||||
try {
|
try {
|
||||||
const allowed = JSON.parse(jobspySitesSettingRaw);
|
const allowed = JSON.parse(jobspySitesSettingRaw);
|
||||||
@ -157,11 +160,11 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
detail: `JobSpy: scraping ${jobSpySites.join(', ')}...`,
|
detail: `JobSpy: scraping ${jobSpySites.join(', ')}...`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const jobspyLocationSetting = await settingsRepo.getSetting('jobspyLocation');
|
const jobspyLocationSetting = settings.jobspyLocation;
|
||||||
const jobspyResultsWantedSetting = await settingsRepo.getSetting('jobspyResultsWanted');
|
const jobspyResultsWantedSetting = settings.jobspyResultsWanted;
|
||||||
const jobspyHoursOldSetting = await settingsRepo.getSetting('jobspyHoursOld');
|
const jobspyHoursOldSetting = settings.jobspyHoursOld;
|
||||||
const jobspyCountryIndeedSetting = await settingsRepo.getSetting('jobspyCountryIndeed');
|
const jobspyCountryIndeedSetting = settings.jobspyCountryIndeed;
|
||||||
const jobspyLinkedinFetchDescriptionSetting = await settingsRepo.getSetting('jobspyLinkedinFetchDescription');
|
const jobspyLinkedinFetchDescriptionSetting = settings.jobspyLinkedinFetchDescription;
|
||||||
|
|
||||||
const jobSpyResult = await runJobSpy({
|
const jobSpyResult = await runJobSpy({
|
||||||
sites: jobSpySites,
|
sites: jobSpySites,
|
||||||
@ -170,7 +173,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
|||||||
resultsWanted: jobspyResultsWantedSetting ? parseInt(jobspyResultsWantedSetting, 10) : undefined,
|
resultsWanted: jobspyResultsWantedSetting ? parseInt(jobspyResultsWantedSetting, 10) : undefined,
|
||||||
hoursOld: jobspyHoursOldSetting ? parseInt(jobspyHoursOldSetting, 10) : undefined,
|
hoursOld: jobspyHoursOldSetting ? parseInt(jobspyHoursOldSetting, 10) : undefined,
|
||||||
countryIndeed: jobspyCountryIndeedSetting ?? undefined,
|
countryIndeed: jobspyCountryIndeedSetting ?? undefined,
|
||||||
linkedinFetchDescription: jobspyLinkedinFetchDescriptionSetting !== null ? jobspyLinkedinFetchDescriptionSetting === '1' : undefined,
|
linkedinFetchDescription: jobspyLinkedinFetchDescriptionSetting !== null && jobspyLinkedinFetchDescriptionSetting !== undefined ? jobspyLinkedinFetchDescriptionSetting === '1' : undefined,
|
||||||
});
|
});
|
||||||
if (!jobSpyResult.success) {
|
if (!jobSpyResult.success) {
|
||||||
sourceErrors.push(`jobspy: ${jobSpyResult.error ?? 'unknown error'}`);
|
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
|
// Pass existing URLs to avoid clicking "Apply" on jobs we already have
|
||||||
const existingJobUrls = await jobsRepo.getAllJobUrls();
|
const existingJobUrls = await jobsRepo.getAllJobUrls();
|
||||||
|
|
||||||
const gradcrackerMaxJobsSetting = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm');
|
const gradcrackerMaxJobsSetting = settings.gradcrackerMaxJobsPerTerm;
|
||||||
const gradcrackerMaxJobs = gradcrackerMaxJobsSetting ? parseInt(gradcrackerMaxJobsSetting, 10) : 50;
|
const gradcrackerMaxJobs = gradcrackerMaxJobsSetting ? parseInt(gradcrackerMaxJobsSetting, 10) : 50;
|
||||||
|
|
||||||
const crawlerResult = await runCrawler({
|
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)
|
// 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 ukvisajobsMaxJobs = ukvisajobsMaxJobsSetting ? parseInt(ukvisajobsMaxJobsSetting, 10) : 50;
|
||||||
|
|
||||||
const ukVisaResult = await runUkVisaJobs({
|
const ukVisaResult = await runUkVisaJobs({
|
||||||
|
|||||||
@ -36,6 +36,7 @@ vi.mock('../repositories/pipeline.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../repositories/settings.js', () => ({
|
vi.mock('../repositories/settings.js', () => ({
|
||||||
getSetting: vi.fn().mockResolvedValue(null),
|
getSetting: vi.fn().mockResolvedValue(null),
|
||||||
|
getAllSettings: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../services/crawler.js', () => ({
|
vi.mock('../services/crawler.js', () => ({
|
||||||
|
|||||||
@ -24,12 +24,28 @@ export type SettingKey = 'model'
|
|||||||
| 'jobspySites'
|
| 'jobspySites'
|
||||||
| 'jobspyLinkedinFetchDescription'
|
| 'jobspyLinkedinFetchDescription'
|
||||||
| 'showSponsorInfo'
|
| 'showSponsorInfo'
|
||||||
|
| 'openrouterApiKey'
|
||||||
|
| 'rxresumeEmail'
|
||||||
|
| 'rxresumePassword'
|
||||||
|
| 'basicAuthUser'
|
||||||
|
| 'basicAuthPassword'
|
||||||
|
| 'ukvisajobsEmail'
|
||||||
|
| 'ukvisajobsPassword'
|
||||||
|
| 'webhookSecret'
|
||||||
|
|
||||||
export async function getSetting(key: SettingKey): Promise<string | null> {
|
export async function getSetting(key: SettingKey): Promise<string | null> {
|
||||||
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
const [row] = await db.select().from(settings).where(eq(settings.key, key))
|
||||||
return row?.value ?? null
|
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> {
|
export async function setSetting(key: SettingKey, value: string | null): Promise<void> {
|
||||||
const now = new Date().toISOString()
|
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", () => ({
|
vi.mock("../repositories/settings.js", () => ({
|
||||||
getSetting: vi.fn(),
|
getSetting: vi.fn(),
|
||||||
|
getAllSettings: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const originalEnv = process.env;
|
const originalEnv = process.env;
|
||||||
|
|||||||
@ -47,6 +47,7 @@ vi.mock('fs', () => ({
|
|||||||
|
|
||||||
vi.mock('../repositories/settings.js', () => ({
|
vi.mock('../repositories/settings.js', () => ({
|
||||||
getSetting: vi.fn().mockResolvedValue(null),
|
getSetting: vi.fn().mockResolvedValue(null),
|
||||||
|
getAllSettings: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./projectSelection.js', () => ({
|
vi.mock('./projectSelection.js', () => ({
|
||||||
|
|||||||
@ -49,6 +49,7 @@ vi.mock('fs', () => ({
|
|||||||
|
|
||||||
vi.mock('../repositories/settings.js', () => ({
|
vi.mock('../repositories/settings.js', () => ({
|
||||||
getSetting: vi.fn().mockResolvedValue(null),
|
getSetting: vi.fn().mockResolvedValue(null),
|
||||||
|
getAllSettings: vi.fn().mockResolvedValue({}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('./projectSelection.js', () => ({
|
vi.mock('./projectSelection.js', () => ({
|
||||||
|
|||||||
@ -38,8 +38,10 @@ export async function pickProjectIdsForJob(args: {
|
|||||||
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
return fallbackPickProjectIds(args.jobDescription, args.eligibleProjects, desiredCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
const overrideModel = await getSetting('model');
|
const [overrideModel, overrideModelProjectSelection] = await Promise.all([
|
||||||
const overrideModelProjectSelection = await getSetting('modelProjectSelection');
|
getSetting('model'),
|
||||||
|
getSetting('modelProjectSelection'),
|
||||||
|
]);
|
||||||
// Precedence: Project-specific override > Global override > Env var > Default
|
// Precedence: Project-specific override > Global override > Env var > Default
|
||||||
const model = overrideModelProjectSelection || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
const model = overrideModelProjectSelection || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
|
|
||||||
|
|||||||
@ -44,8 +44,10 @@ export async function scoreJobSuitability(
|
|||||||
return mockScore(job);
|
return mockScore(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
const overrideModel = await getSetting('model');
|
const [overrideModel, overrideModelScorer] = await Promise.all([
|
||||||
const overrideModelScorer = await getSetting('modelScorer');
|
getSetting('model'),
|
||||||
|
getSetting('modelScorer'),
|
||||||
|
]);
|
||||||
// Precedence: Scorer-specific override > Global override > Env var > Default
|
// Precedence: Scorer-specific override > Global override > Env var > Default
|
||||||
const model = overrideModelScorer || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
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' };
|
return { success: false, error: 'API key not configured' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const overrideModel = await getSetting('model');
|
const [overrideModel, overrideModelTailoring] = await Promise.all([
|
||||||
const overrideModelTailoring = await getSetting('modelTailoring');
|
getSetting('model'),
|
||||||
|
getSetting('modelTailoring'),
|
||||||
|
]);
|
||||||
// Precedence: Tailoring-specific override > Global override > Env var > Default
|
// Precedence: Tailoring-specific override > Global override > Env var > Default
|
||||||
const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
const model = overrideModelTailoring || overrideModel || process.env.MODEL || 'openai/gpt-4o-mini';
|
||||||
const prompt = buildTailoringPrompt(profile, jobDescription);
|
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(),
|
jobspySites: z.array(z.string().trim().min(1).max(50)).max(20).nullable().optional(),
|
||||||
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
jobspyLinkedinFetchDescription: z.boolean().nullable().optional(),
|
||||||
showSponsorInfo: 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>;
|
export type UpdateSettingsInput = z.infer<typeof updateSettingsSchema>;
|
||||||
|
|||||||
@ -383,4 +383,13 @@ export interface AppSettings {
|
|||||||
showSponsorInfo: boolean;
|
showSponsorInfo: boolean;
|
||||||
defaultShowSponsorInfo: boolean;
|
defaultShowSponsorInfo: boolean;
|
||||||
overrideShowSponsorInfo: boolean | null;
|
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