import React, { useEffect, useState } from "react" import { Settings } from "lucide-react" import { toast } from "sonner" import { useForm, FormProvider } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { PageHeader } from "@client/components/layout" import { Accordion } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" import type { AppSettings, JobStatus, ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types" import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema" import * as api from "@client/api" import { arraysEqual } from "@/lib/utils" import { resumeProjectsEqual } from "@client/pages/settings/utils" import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection" import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection" import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection" import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection" import { JobspySection } from "@client/pages/settings/components/JobspySection" import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection" import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection" import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection" import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection" import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection" const DEFAULT_FORM_VALUES: UpdateSettingsInput = { model: "", modelScorer: "", modelTailoring: "", modelProjectSelection: "", pipelineWebhookUrl: "", jobCompleteWebhookUrl: "", resumeProjects: null, rxresumeBaseResumeId: null, ukvisajobsMaxJobs: null, gradcrackerMaxJobsPerTerm: null, searchTerms: null, jobspyLocation: null, jobspyResultsWanted: null, jobspyHoursOld: null, jobspyCountryIndeed: null, jobspySites: null, jobspyLinkedinFetchDescription: null, showSponsorInfo: null, openrouterApiKey: "", rxresumeEmail: "", rxresumePassword: "", basicAuthUser: "", basicAuthPassword: "", ukvisajobsEmail: "", ukvisajobsPassword: "", webhookSecret: "", enableBasicAuth: false, } const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { model: null, modelScorer: null, modelTailoring: null, modelProjectSelection: null, pipelineWebhookUrl: null, jobCompleteWebhookUrl: null, resumeProjects: null, rxresumeBaseResumeId: null, ukvisajobsMaxJobs: null, gradcrackerMaxJobsPerTerm: null, searchTerms: null, jobspyLocation: null, jobspyResultsWanted: null, jobspyHoursOld: null, jobspyCountryIndeed: null, jobspySites: null, jobspyLinkedinFetchDescription: null, showSponsorInfo: null, openrouterApiKey: null, rxresumeEmail: null, rxresumePassword: null, basicAuthUser: null, basicAuthPassword: null, ukvisajobsEmail: null, ukvisajobsPassword: null, webhookSecret: null, enableBasicAuth: undefined, } const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ model: data.overrideModel ?? "", modelScorer: data.overrideModelScorer ?? "", modelTailoring: data.overrideModelTailoring ?? "", modelProjectSelection: data.overrideModelProjectSelection ?? "", pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "", jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "", resumeProjects: data.resumeProjects, rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null, ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs, gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm, searchTerms: data.overrideSearchTerms, jobspyLocation: data.overrideJobspyLocation, jobspyResultsWanted: data.overrideJobspyResultsWanted, jobspyHoursOld: data.overrideJobspyHoursOld, jobspyCountryIndeed: data.overrideJobspyCountryIndeed, jobspySites: data.overrideJobspySites, jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription, showSponsorInfo: data.overrideShowSponsorInfo, openrouterApiKey: "", rxresumeEmail: data.rxresumeEmail ?? "", rxresumePassword: "", basicAuthUser: data.basicAuthUser ?? "", basicAuthPassword: "", ukvisajobsEmail: data.ukvisajobsEmail ?? "", ukvisajobsPassword: "", webhookSecret: "", enableBasicAuth: data.basicAuthActive, }) const normalizeString = (value: string | null | undefined) => { const trimmed = value?.trim() return trimmed ? trimmed : null } const normalizePrivateInput = (value: string | null | undefined) => { const trimmed = value?.trim() if (trimmed === "") return null return trimmed || undefined } const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => { if (!left && !right) return true if (!left || !right) return false return arraysEqual(left, right) } const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => { if (!left && !right) return true if (!left || !right) return false return arraysEqual(left.slice().sort(), right.slice().sort()) } const nullIfSame = (value: T | null | undefined, defaultValue: T) => value === defaultValue ? null : value ?? null const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) => isSameStringList(value, defaultValue) ? null : value ?? null const normalizeResumeProjectsForCatalog = ( catalog: ResumeProjectCatalogItem[], current: ResumeProjectsSettings | null ): ResumeProjectsSettings | null => { const allowed = new Set(catalog.map((project) => project.id)) const base = current ?? { maxProjects: 0, lockedProjectIds: catalog.filter((project) => project.isVisibleInBase).map((project) => project.id), aiSelectableProjectIds: [], } const lockedProjectIds = base.lockedProjectIds.filter((id) => allowed.has(id)) const lockedSet = new Set(lockedProjectIds) const aiSelectableProjectIds = (current ? base.aiSelectableProjectIds : catalog.map((project) => project.id) ) .filter((id) => allowed.has(id)) .filter((id) => !lockedSet.has(id)) const maxProjectsRaw = Number.isFinite(base.maxProjects) ? base.maxProjects : 0 const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw)) const maxProjects = Math.min( catalog.length, Math.max(lockedProjectIds.length, maxProjectsInt, 3) ) return { maxProjects, lockedProjectIds, aiSelectableProjectIds } } const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) => isSameSortedStringList(value, defaultValue) ? null : value ?? null const getDerivedSettings = (settings: AppSettings | null) => { const profileProjects = settings?.profileProjects ?? [] return { model: { effective: settings?.model ?? "", default: settings?.defaultModel ?? "", scorer: settings?.modelScorer ?? "", tailoring: settings?.modelTailoring ?? "", projectSelection: settings?.modelProjectSelection ?? "", }, pipelineWebhook: { effective: settings?.pipelineWebhookUrl ?? "", default: settings?.defaultPipelineWebhookUrl ?? "", }, jobCompleteWebhook: { effective: settings?.jobCompleteWebhookUrl ?? "", default: settings?.defaultJobCompleteWebhookUrl ?? "", }, ukvisajobs: { effective: settings?.ukvisajobsMaxJobs ?? 50, default: settings?.defaultUkvisajobsMaxJobs ?? 50, }, gradcracker: { effective: settings?.gradcrackerMaxJobsPerTerm ?? 50, default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50, }, searchTerms: { effective: settings?.searchTerms ?? [], default: settings?.defaultSearchTerms ?? [], }, jobspy: { location: { effective: settings?.jobspyLocation ?? "", default: settings?.defaultJobspyLocation ?? "", }, resultsWanted: { effective: settings?.jobspyResultsWanted ?? 200, default: settings?.defaultJobspyResultsWanted ?? 200, }, hoursOld: { effective: settings?.jobspyHoursOld ?? 72, default: settings?.defaultJobspyHoursOld ?? 72, }, countryIndeed: { effective: settings?.jobspyCountryIndeed ?? "", default: settings?.defaultJobspyCountryIndeed ?? "", }, sites: { effective: settings?.jobspySites ?? ["indeed", "linkedin"], default: settings?.defaultJobspySites ?? ["indeed", "linkedin"], }, linkedinFetchDescription: { effective: settings?.jobspyLinkedinFetchDescription ?? true, default: settings?.defaultJobspyLinkedinFetchDescription ?? true, }, }, display: { effective: settings?.showSponsorInfo ?? true, default: settings?.defaultShowSponsorInfo ?? true, }, envSettings: { readable: { rxresumeEmail: settings?.rxresumeEmail ?? "", ukvisajobsEmail: settings?.ukvisajobsEmail ?? "", basicAuthUser: settings?.basicAuthUser ?? "", }, private: { openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null, rxresumePasswordHint: settings?.rxresumePasswordHint ?? null, ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null, basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null, webhookSecretHint: settings?.webhookSecretHint ?? null, }, basicAuthActive: settings?.basicAuthActive ?? false, }, defaultResumeProjects: settings?.defaultResumeProjects ?? null, profileProjects, maxProjectsTotal: profileProjects.length, } } export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) const [statusesToClear, setStatusesToClear] = useState(['discovered']) const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState(null) const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState(null) const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] = useState(false) const methods = useForm({ resolver: zodResolver(updateSettingsSchema), mode: "onChange", defaultValues: DEFAULT_FORM_VALUES, }) const { handleSubmit, reset, setError, setValue, getValues, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods const hasRxResumeAccess = Boolean( settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint ) useEffect(() => { let isMounted = true setIsLoading(true) api .getSettings() .then((data) => { if (!isMounted) return setSettings(data) reset(mapSettingsToForm(data)) }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" toast.error(message) }) .finally(() => { if (!isMounted) return setIsLoading(false) }) return () => { isMounted = false } }, [reset]) useEffect(() => { if (!settings) return const storedId = settings.rxresumeBaseResumeId ?? null setRxResumeBaseResumeIdDraft(storedId) setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false }) setRxResumeProjectsOverride(null) }, [settings, setValue]) useEffect(() => { let isMounted = true const controller = new AbortController() if (!rxResumeBaseResumeIdDraft) { setRxResumeProjectsOverride(null) return () => { isMounted = false controller.abort() } } if (!hasRxResumeAccess) return () => { isMounted = false controller.abort() } setIsFetchingRxResumeProjects(true) api .getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal) .then((projects) => { if (!isMounted) return setRxResumeProjectsOverride(projects) const normalized = normalizeResumeProjectsForCatalog( projects, getValues("resumeProjects") ?? null ) if (normalized) { setValue("resumeProjects", normalized, { shouldDirty: true }) } }) .catch((error) => { if (!isMounted || error.name === 'AbortError') return const message = error instanceof Error ? error.message : "Failed to load RxResume projects" toast.error(message) setRxResumeProjectsOverride(null) }) .finally(() => { if (!isMounted) return setIsFetchingRxResumeProjects(false) }) return () => { isMounted = false controller.abort() } }, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]) const derived = getDerivedSettings(settings) const { model, pipelineWebhook, jobCompleteWebhook, ukvisajobs, gradcracker, searchTerms, jobspy, display, envSettings, defaultResumeProjects, profileProjects, maxProjectsTotal, } = derived const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects const effectiveMaxProjectsTotal = effectiveProfileProjects.length const watchedValues = watch() const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0 const canSave = isDirty && isValid const onSave = async (data: UpdateSettingsInput) => { if (!settings) return if (data.enableBasicAuth && !settings.basicAuthActive) { const password = data.basicAuthPassword?.trim() ?? "" if (!password) { setError("basicAuthPassword", { type: "manual", message: "Password is required when basic auth is enabled", }) return } } try { setIsSaving(true) // Prepare payload: nullify if equal to default const resumeProjectsData = data.resumeProjects const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects)) ? null : resumeProjectsData const envPayload: Partial = {} if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) { envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail) } if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) { envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail) } if (data.enableBasicAuth === false) { envPayload.basicAuthUser = null envPayload.basicAuthPassword = null } else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) { // If enabling basic auth or changing either field, ensure we send at least the username // to keep the pair consistent in the backend. envPayload.basicAuthUser = normalizeString(data.basicAuthUser) if (dirtyFields.basicAuthPassword) { const value = normalizePrivateInput(data.basicAuthPassword) if (value !== undefined) envPayload.basicAuthPassword = value } } if (dirtyFields.openrouterApiKey) { const value = normalizePrivateInput(data.openrouterApiKey) if (value !== undefined) envPayload.openrouterApiKey = value } if (dirtyFields.rxresumePassword) { const value = normalizePrivateInput(data.rxresumePassword) if (value !== undefined) envPayload.rxresumePassword = value } if (dirtyFields.ukvisajobsPassword) { const value = normalizePrivateInput(data.ukvisajobsPassword) if (value !== undefined) envPayload.ukvisajobsPassword = value } if (dirtyFields.webhookSecret) { const value = normalizePrivateInput(data.webhookSecret) if (value !== undefined) envPayload.webhookSecret = value } const payload: UpdateSettingsInput = { model: normalizeString(data.model), modelScorer: normalizeString(data.modelScorer), modelTailoring: normalizeString(data.modelTailoring), modelProjectSelection: normalizeString(data.modelProjectSelection), pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl), jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl), resumeProjects: resumeProjectsOverride, rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId), ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default), gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default), searchTerms: nullIfSameList(data.searchTerms, searchTerms.default), jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.default), jobspyResultsWanted: nullIfSame(data.jobspyResultsWanted, jobspy.resultsWanted.default), jobspyHoursOld: nullIfSame(data.jobspyHoursOld, jobspy.hoursOld.default), jobspyCountryIndeed: nullIfSame(data.jobspyCountryIndeed, jobspy.countryIndeed.default), jobspySites: nullIfSameSortedList(data.jobspySites, jobspy.sites.default), jobspyLinkedinFetchDescription: nullIfSame( data.jobspyLinkedinFetchDescription, jobspy.linkedinFetchDescription.default ), showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), ...envPayload, } // Remove virtual field because the backend doesn't expect it // this exists only to toggle the UI // need to track it so that the save button is enabled when it changes delete payload.enableBasicAuth const updated = await api.updateSettings(payload) setSettings(updated) reset(mapSettingsToForm(updated)) toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" toast.error(message) } finally { setIsSaving(false) } } const handleClearDatabase = async () => { try { setIsSaving(true) const result = await api.clearDatabase() toast.success("Database cleared", { description: `Deleted ${result.jobsDeleted} jobs.` }) } catch (error) { const message = error instanceof Error ? error.message : "Failed to clear database" toast.error(message) } finally { setIsSaving(false) } } const handleClearByStatuses = async () => { if (statusesToClear.length === 0) { toast.error("No statuses selected") return } try { setIsSaving(true) let totalDeleted = 0 const results: string[] = [] for (const status of statusesToClear) { const result = await api.deleteJobsByStatus(status) totalDeleted += result.count if (result.count > 0) { results.push(`${result.count} ${status}`) } } if (totalDeleted > 0) { toast.success("Jobs cleared", { description: `Deleted ${totalDeleted} jobs: ${results.join(', ')}`, }) } else { toast.info("No jobs found", { description: `No jobs with selected statuses found`, }) } } catch (error) { const message = error instanceof Error ? error.message : "Failed to clear jobs" toast.error(message) } finally { setIsSaving(false) } } const toggleStatusToClear = (status: JobStatus) => { setStatusesToClear(prev => prev.includes(status) ? prev.filter(s => s !== status) : [...prev, status] ) } const handleReset = async () => { try { setIsSaving(true) const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD) setSettings(updated) reset(mapSettingsToForm(updated)) toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" toast.error(message) } finally { setIsSaving(false) } } return (
{ setRxResumeBaseResumeIdDraft(value) setValue("rxresumeBaseResumeId", value, { shouldDirty: true }) }} hasRxResumeAccess={hasRxResumeAccess} profileProjects={effectiveProfileProjects} lockedCount={lockedCount} maxProjectsTotal={effectiveMaxProjectsTotal} isProjectsLoading={isFetchingRxResumeProjects} isLoading={isLoading} isSaving={isSaving} />
{Object.keys(errors).length > 0 && (
Please fix the errors before saving.
)}
) }