settings page can pull and save resume details from v4 api template
This commit is contained in:
parent
4798846483
commit
7a358db317
@ -247,6 +247,13 @@ export async function getRxResumes(): Promise<{ id: string; name: string }[]> {
|
||||
return data.resumes;
|
||||
}
|
||||
|
||||
export async function getRxResumeProjects(resumeId: string): Promise<ResumeProjectCatalogItem[]> {
|
||||
const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>(
|
||||
`/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`
|
||||
);
|
||||
return data.projects;
|
||||
}
|
||||
|
||||
|
||||
// Database API
|
||||
export async function clearDatabase(): Promise<{
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { Check } from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Progress } from "@/components/ui/progress"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import { cn } from "@/lib/utils"
|
||||
@ -13,17 +12,15 @@ import * as api from "@client/api"
|
||||
import { useSettings } from "@client/hooks/useSettings"
|
||||
import { SettingsInput } from "@client/pages/settings/components/SettingsInput"
|
||||
import { formatSecretHint } from "@client/pages/settings/utils"
|
||||
import type { ResumeProfile, ValidationResult } from "@shared/types"
|
||||
import type { ValidationResult } from "@shared/types"
|
||||
|
||||
type ValidationState = ValidationResult & { checked: boolean }
|
||||
|
||||
export const OnboardingGate: React.FC = () => {
|
||||
const { settings, isLoading: settingsLoading, refreshSettings } = useSettings()
|
||||
const [isSavingEnv, setIsSavingEnv] = useState(false)
|
||||
const [isUploadingResume, setIsUploadingResume] = useState(false)
|
||||
const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false)
|
||||
const [isValidatingRxresume, setIsValidatingRxresume] = useState(false)
|
||||
const [isValidatingResume, setIsValidatingResume] = useState(false)
|
||||
const [openrouterValidation, setOpenrouterValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
@ -34,34 +31,11 @@ export const OnboardingGate: React.FC = () => {
|
||||
message: null,
|
||||
checked: false,
|
||||
})
|
||||
const [resumeValidation, setResumeValidation] = useState<ValidationState>({
|
||||
valid: false,
|
||||
message: null,
|
||||
checked: false,
|
||||
})
|
||||
const [currentStep, setCurrentStep] = useState<string | null>(null)
|
||||
|
||||
const [openrouterApiKey, setOpenrouterApiKey] = useState("")
|
||||
const [rxresumeEmail, setRxresumeEmail] = useState("")
|
||||
const [rxresumePassword, setRxresumePassword] = useState("")
|
||||
const [resumeFile, setResumeFile] = useState<File | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const validateResume = useCallback(async () => {
|
||||
setIsValidatingResume(true)
|
||||
try {
|
||||
const result = await api.validateResumeJson()
|
||||
setResumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Resume validation failed"
|
||||
const result = { valid: false, message }
|
||||
setResumeValidation({ ...result, checked: true })
|
||||
return result
|
||||
} finally {
|
||||
setIsValidatingResume(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const validateOpenrouter = useCallback(async (apiKey?: string) => {
|
||||
setIsValidatingOpenrouter(true)
|
||||
@ -98,10 +72,8 @@ export const OnboardingGate: React.FC = () => {
|
||||
const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint)
|
||||
const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim())
|
||||
const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint)
|
||||
const hasBaseResume = resumeValidation.valid
|
||||
|
||||
const shouldOpen = Boolean(settings && !settingsLoading)
|
||||
&& !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid)
|
||||
&& !(openrouterValidation.valid && rxresumeValidation.valid)
|
||||
|
||||
const openrouterCurrent = settings?.openrouterApiKeyHint
|
||||
? formatSecretHint(settings.openrouterApiKeyHint)
|
||||
@ -127,14 +99,8 @@ export const OnboardingGate: React.FC = () => {
|
||||
subtitle: "RxResume login",
|
||||
complete: rxresumeValidation.valid,
|
||||
},
|
||||
{
|
||||
id: "resume",
|
||||
label: "Resume JSON",
|
||||
subtitle: "Upload your file",
|
||||
complete: resumeValidation.valid,
|
||||
},
|
||||
],
|
||||
[openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.valid]
|
||||
[openrouterValidation.valid, rxresumeValidation.valid]
|
||||
)
|
||||
|
||||
const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id
|
||||
@ -151,7 +117,6 @@ export const OnboardingGate: React.FC = () => {
|
||||
const results = await Promise.allSettled([
|
||||
validateOpenrouter(),
|
||||
validateRxresume(),
|
||||
validateResume(),
|
||||
])
|
||||
|
||||
const failed = results.find((result) => result.status === "rejected")
|
||||
@ -160,13 +125,13 @@ export const OnboardingGate: React.FC = () => {
|
||||
const message = reason instanceof Error ? reason.message : "Validation checks failed"
|
||||
toast.error(message)
|
||||
}
|
||||
}, [settings, validateOpenrouter, validateRxresume, validateResume])
|
||||
}, [settings, validateOpenrouter, validateRxresume])
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings || settingsLoading) return
|
||||
if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return
|
||||
if (openrouterValidation.checked || rxresumeValidation.checked) return
|
||||
void runAllValidations()
|
||||
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations])
|
||||
}, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, runAllValidations])
|
||||
|
||||
const handleRefresh = async () => {
|
||||
const results = await Promise.allSettled([refreshSettings(), runAllValidations()])
|
||||
@ -254,58 +219,17 @@ export const OnboardingGate: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleUploadResume = async (): Promise<boolean> => {
|
||||
if (!resumeFile) {
|
||||
const validation = await validateResume()
|
||||
if (!validation.valid) {
|
||||
toast.info(validation.message || "Upload your resume JSON to continue")
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
setIsUploadingResume(true)
|
||||
const text = await resumeFile.text()
|
||||
let parsed: ResumeProfile
|
||||
try {
|
||||
parsed = JSON.parse(text) as ResumeProfile
|
||||
} catch {
|
||||
throw new Error("Resume JSON is invalid. Export the base.json from RxResume.")
|
||||
}
|
||||
|
||||
await api.uploadProfile(parsed)
|
||||
await validateResume()
|
||||
setResumeFile(null)
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = ""
|
||||
}
|
||||
toast.success("Resume uploaded")
|
||||
return true
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to upload resume"
|
||||
toast.error(message)
|
||||
return false
|
||||
} finally {
|
||||
setIsUploadingResume(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resumeFileName = resumeFile?.name || ""
|
||||
const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0
|
||||
const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0
|
||||
const completedSteps = steps.filter((step) => step.complete).length
|
||||
const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0
|
||||
const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume
|
||||
const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume
|
||||
const canGoBack = stepIndex > 0
|
||||
const primaryLabel = currentStep === "resume"
|
||||
? (resumeValidation.valid ? "Finish" : "Upload and validate")
|
||||
: currentStep === "openrouter"
|
||||
? (openrouterValidation.valid ? "Revalidate" : "Validate")
|
||||
: currentStep === "rxresume"
|
||||
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
|
||||
: "Validate"
|
||||
const primaryLabel = currentStep === "openrouter"
|
||||
? (openrouterValidation.valid ? "Revalidate" : "Validate")
|
||||
: currentStep === "rxresume"
|
||||
? (rxresumeValidation.valid ? "Revalidate" : "Validate")
|
||||
: "Validate"
|
||||
|
||||
const handlePrimaryAction = async () => {
|
||||
if (!currentStep) return
|
||||
@ -317,13 +241,6 @@ export const OnboardingGate: React.FC = () => {
|
||||
await handleSaveRxresume()
|
||||
return
|
||||
}
|
||||
if (currentStep === "resume") {
|
||||
if (hasBaseResume) {
|
||||
await handleRefresh()
|
||||
return
|
||||
}
|
||||
await handleUploadResume()
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
@ -348,7 +265,7 @@ export const OnboardingGate: React.FC = () => {
|
||||
</AlertDialogHeader>
|
||||
|
||||
<Tabs value={currentStep} onValueChange={setCurrentStep}>
|
||||
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-3">
|
||||
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 border-b border-border/60 bg-transparent p-0 text-left sm:grid-cols-2">
|
||||
{steps.map((step, index) => {
|
||||
const isActive = step.id === currentStep
|
||||
const isComplete = step.complete
|
||||
@ -439,30 +356,6 @@ export const OnboardingGate: React.FC = () => {
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="resume" className="space-y-4 pt-6">
|
||||
<div>
|
||||
<p className="text-sm font-semibold">Upload your resume JSON</p>
|
||||
<p className="text-xs text-muted-foreground">Use the JSON export you downloaded from v4.rxresu.me.</p>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-[1fr_auto] md:items-end">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="resumeFile" className="text-sm font-medium">
|
||||
Resume JSON
|
||||
</label>
|
||||
<Input
|
||||
id="resumeFile"
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="application/json,.json"
|
||||
onChange={(event) => setResumeFile(event.target.files?.[0] ?? null)}
|
||||
disabled={isUploadingResume}
|
||||
/>
|
||||
{resumeFileName && (
|
||||
<p className="text-xs text-muted-foreground">Selected: {resumeFileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -7,7 +7,7 @@ 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 } from "@shared/types"
|
||||
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"
|
||||
@ -19,9 +19,9 @@ import { GradcrackerSection } from "@client/pages/settings/components/Gradcracke
|
||||
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 { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection"
|
||||
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: "",
|
||||
@ -31,6 +31,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = {
|
||||
pipelineWebhookUrl: "",
|
||||
jobCompleteWebhookUrl: "",
|
||||
resumeProjects: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
ukvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: null,
|
||||
@ -60,6 +61,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = {
|
||||
pipelineWebhookUrl: null,
|
||||
jobCompleteWebhookUrl: null,
|
||||
resumeProjects: null,
|
||||
rxresumeBaseResumeId: null,
|
||||
ukvisajobsMaxJobs: null,
|
||||
gradcrackerMaxJobsPerTerm: null,
|
||||
searchTerms: null,
|
||||
@ -89,6 +91,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({
|
||||
pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "",
|
||||
jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "",
|
||||
resumeProjects: data.resumeProjects,
|
||||
rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null,
|
||||
ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs,
|
||||
gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm,
|
||||
searchTerms: data.overrideSearchTerms,
|
||||
@ -139,6 +142,35 @@ const nullIfSame = <T,>(value: T | null | undefined, defaultValue: T) =>
|
||||
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 = (base.aiSelectableProjectIds.length
|
||||
? 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
|
||||
|
||||
@ -230,6 +262,9 @@ export const SettingsPage: React.FC = () => {
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['discovered'])
|
||||
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | null>(null)
|
||||
const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState<ResumeProjectCatalogItem[] | null>(null)
|
||||
const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] = useState(false)
|
||||
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
resolver: zodResolver(updateSettingsSchema),
|
||||
@ -237,7 +272,19 @@ export const SettingsPage: React.FC = () => {
|
||||
defaultValues: DEFAULT_FORM_VALUES,
|
||||
})
|
||||
|
||||
const { handleSubmit, reset, setError, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods
|
||||
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
|
||||
@ -263,6 +310,58 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [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
|
||||
|
||||
if (!rxResumeBaseResumeIdDraft) {
|
||||
setRxResumeProjectsOverride(null)
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasRxResumeAccess) return () => {
|
||||
isMounted = false
|
||||
}
|
||||
|
||||
setIsFetchingRxResumeProjects(true)
|
||||
api
|
||||
.getRxResumeProjects(rxResumeBaseResumeIdDraft)
|
||||
.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) 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
|
||||
}
|
||||
}, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue])
|
||||
|
||||
const derived = getDerivedSettings(settings)
|
||||
const {
|
||||
model,
|
||||
@ -279,6 +378,9 @@ export const SettingsPage: React.FC = () => {
|
||||
maxProjectsTotal,
|
||||
} = derived
|
||||
|
||||
const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects
|
||||
const effectiveMaxProjectsTotal = effectiveProfileProjects.length
|
||||
|
||||
const watchedValues = watch()
|
||||
const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0
|
||||
|
||||
@ -357,6 +459,7 @@ export const SettingsPage: React.FC = () => {
|
||||
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),
|
||||
@ -502,10 +605,17 @@ export const SettingsPage: React.FC = () => {
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
<ResumeProjectsSection
|
||||
profileProjects={profileProjects}
|
||||
<ReactiveResumeSection
|
||||
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
|
||||
setRxResumeBaseResumeIdDraft={(value) => {
|
||||
setRxResumeBaseResumeIdDraft(value)
|
||||
setValue("rxresumeBaseResumeId", value, { shouldDirty: true })
|
||||
}}
|
||||
hasRxResumeAccess={hasRxResumeAccess}
|
||||
profileProjects={effectiveProfileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={maxProjectsTotal}
|
||||
maxProjectsTotal={effectiveMaxProjectsTotal}
|
||||
isProjectsLoading={isFetchingRxResumeProjects}
|
||||
isLoading={isLoading}
|
||||
isSaving={isSaving}
|
||||
/>
|
||||
|
||||
@ -1,16 +1,29 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { Controller, useFormContext } from "react-hook-form"
|
||||
import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react"
|
||||
|
||||
import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { clampInt } from "@/lib/utils"
|
||||
import type { ResumeProjectCatalogItem } from "@shared/types"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
import * as api from "../../../api"
|
||||
|
||||
type ReactiveResumeSectionProps = {
|
||||
rxResumeBaseResumeIdDraft: string | null
|
||||
setRxResumeBaseResumeIdDraft: (value: string | null) => void
|
||||
hasRxResumeApiKey: boolean
|
||||
// True when v4 credentials or v5 API key are configured.
|
||||
hasRxResumeAccess: boolean
|
||||
profileProjects: ResumeProjectCatalogItem[]
|
||||
lockedCount: number
|
||||
maxProjectsTotal: number
|
||||
isProjectsLoading: boolean
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
@ -18,16 +31,21 @@ type ReactiveResumeSectionProps = {
|
||||
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
rxResumeBaseResumeIdDraft,
|
||||
setRxResumeBaseResumeIdDraft,
|
||||
hasRxResumeApiKey,
|
||||
hasRxResumeAccess,
|
||||
profileProjects,
|
||||
lockedCount,
|
||||
maxProjectsTotal,
|
||||
isProjectsLoading,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
const [resumes, setResumes] = useState<{ id: string; name: string }[]>([])
|
||||
const [isFetchingResumes, setIsFetchingResumes] = useState(false)
|
||||
const [fetchError, setFetchError] = useState<string | null>(null)
|
||||
|
||||
const fetchResumes = async () => {
|
||||
if (!hasRxResumeApiKey) return
|
||||
if (!hasRxResumeAccess) return
|
||||
|
||||
setIsFetchingResumes(true)
|
||||
setFetchError(null)
|
||||
@ -42,10 +60,10 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasRxResumeApiKey) {
|
||||
if (hasRxResumeAccess) {
|
||||
fetchResumes()
|
||||
}
|
||||
}, [hasRxResumeApiKey])
|
||||
}, [hasRxResumeAccess])
|
||||
|
||||
return (
|
||||
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
|
||||
@ -54,21 +72,21 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
{!hasRxResumeApiKey ? (
|
||||
{!hasRxResumeAccess ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>API Key Missing</AlertTitle>
|
||||
<AlertTitle>RxResume Access Missing</AlertTitle>
|
||||
<AlertDescription>
|
||||
<code>RXRESUME_API_KEY</code> is not configured in the server environment. Please add it to your <code>.env</code> file.
|
||||
Configure RxResume credentials in settings (email + password) or set <code>RXRESUME_API_KEY</code> to enable access.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<>
|
||||
<Alert className="bg-green-50 border-green-200 dark:bg-green-900/10 dark:border-green-900/20">
|
||||
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
|
||||
<AlertTitle className="text-green-800 dark:text-green-300">API Key Configured</AlertTitle>
|
||||
<AlertTitle className="text-green-800 dark:text-green-300">RxResume Access Ready</AlertTitle>
|
||||
<AlertDescription className="text-green-700 dark:text-green-400">
|
||||
Reactive Resume API integration is active.
|
||||
Reactive Resume access is active.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@ -112,9 +130,141 @@ export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
The selected resume will be used as a template for tailoring. A temporary copy will be created during generation and deleted afterwards.
|
||||
The selected resume will be used as a template for tailoring.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
{!rxResumeBaseResumeIdDraft ? (
|
||||
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
|
||||
Choose a PDF to configure resume projects.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">
|
||||
Max projects to choose
|
||||
</div>
|
||||
<Controller
|
||||
name="resumeProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={field.value?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!field.value) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
field.onChange({ ...field.value, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || isProjectsLoading || !field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.resumeProjects?.maxProjects && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.resumeProjects.maxProjects.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="resumeProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Project</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Visible in template</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">Must Include</TableHead>
|
||||
<TableHead className="text-xs whitespace-wrap sm:whitespace-nowrap">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{project.name || project.id}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date].filter(Boolean).join(" - ")}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || isProjectsLoading || !field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!field.value) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = field.value.lockedProjectIds.slice()
|
||||
const selectableIds = field.value.aiSelectableProjectIds.slice()
|
||||
|
||||
if (isChecked) {
|
||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||
const minCap = lockedIds.length
|
||||
field.onChange({
|
||||
...field.value,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(field.value.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
field.onChange({
|
||||
...field.value,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || isProjectsLoading || !field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!field.value) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = field.value.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,94 +0,0 @@
|
||||
import { describe, it, expect } from "vitest"
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
import { useForm, FormProvider } from "react-hook-form"
|
||||
|
||||
import { Accordion } from "@/components/ui/accordion"
|
||||
import { ResumeProjectsSection } from "./ResumeProjectsSection"
|
||||
import type { ResumeProjectCatalogItem } from "@shared/types"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
const profileProjects: ResumeProjectCatalogItem[] = [
|
||||
{
|
||||
id: "proj-1",
|
||||
name: "Project One",
|
||||
description: "Desc 1",
|
||||
date: "2024",
|
||||
isVisibleInBase: true,
|
||||
},
|
||||
{
|
||||
id: "proj-2",
|
||||
name: "Project Two",
|
||||
description: "Desc 2",
|
||||
date: "2023",
|
||||
isVisibleInBase: false,
|
||||
},
|
||||
]
|
||||
|
||||
const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: UpdateSettingsInput["resumeProjects"] }) => {
|
||||
const methods = useForm<UpdateSettingsInput>({
|
||||
defaultValues: {
|
||||
resumeProjects: initialDraft
|
||||
}
|
||||
})
|
||||
const watched = methods.watch()
|
||||
const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
<Accordion type="multiple" defaultValue={["resume-projects"]}>
|
||||
<ResumeProjectsSection
|
||||
profileProjects={profileProjects}
|
||||
lockedCount={lockedCount}
|
||||
maxProjectsTotal={profileProjects.length}
|
||||
isLoading={false}
|
||||
isSaving={false}
|
||||
/>
|
||||
</Accordion>
|
||||
</FormProvider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
describe("ResumeProjectsSection", () => {
|
||||
it("clamps max projects to the locked count", async () => {
|
||||
render(
|
||||
<ResumeProjectsHarness
|
||||
initialDraft={{
|
||||
maxProjects: 2,
|
||||
lockedProjectIds: ["proj-1"],
|
||||
aiSelectableProjectIds: ["proj-2"],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole("spinbutton")
|
||||
fireEvent.change(input, { target: { value: "0" } })
|
||||
|
||||
await waitFor(() => expect(input).toHaveValue(1))
|
||||
})
|
||||
|
||||
it("locks projects and enforces maxProjects >= locked count", () => {
|
||||
render(
|
||||
<ResumeProjectsHarness
|
||||
initialDraft={{
|
||||
maxProjects: 0,
|
||||
lockedProjectIds: [],
|
||||
aiSelectableProjectIds: ["proj-1"],
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const checkboxes = screen.getAllByRole("checkbox")
|
||||
const lockedCheckbox = checkboxes[0]
|
||||
const aiSelectableCheckbox = checkboxes[1]
|
||||
|
||||
fireEvent.click(lockedCheckbox)
|
||||
|
||||
expect(lockedCheckbox).toBeChecked()
|
||||
expect(aiSelectableCheckbox).toBeChecked()
|
||||
expect(aiSelectableCheckbox).toBeDisabled()
|
||||
|
||||
const input = screen.getByRole("spinbutton")
|
||||
expect(input).toHaveValue(1)
|
||||
})
|
||||
})
|
||||
@ -1,161 +0,0 @@
|
||||
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 { Input } from "@/components/ui/input"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import type { ResumeProjectCatalogItem } from "@shared/types"
|
||||
import { clampInt } from "@/lib/utils"
|
||||
import { UpdateSettingsInput } from "@shared/settings-schema"
|
||||
|
||||
type ResumeProjectsSectionProps = {
|
||||
profileProjects: ResumeProjectCatalogItem[]
|
||||
lockedCount: number
|
||||
maxProjectsTotal: number
|
||||
isLoading: boolean
|
||||
isSaving: boolean
|
||||
}
|
||||
|
||||
export const ResumeProjectsSection: React.FC<ResumeProjectsSectionProps> = ({
|
||||
profileProjects,
|
||||
lockedCount,
|
||||
maxProjectsTotal,
|
||||
isLoading,
|
||||
isSaving,
|
||||
}) => {
|
||||
const { control, formState: { errors } } = useFormContext<UpdateSettingsInput>()
|
||||
|
||||
return (
|
||||
<AccordionItem value="resume-projects" className="border rounded-lg px-4">
|
||||
<AccordionTrigger className="hover:no-underline py-4">
|
||||
<span className="text-base font-semibold">Resume Projects</span>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">Max projects included</div>
|
||||
<Controller
|
||||
name="resumeProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={lockedCount}
|
||||
max={maxProjectsTotal}
|
||||
value={field.value?.maxProjects ?? 0}
|
||||
onChange={(event) => {
|
||||
if (!field.value) return
|
||||
const next = Number(event.target.value)
|
||||
const clamped = clampInt(next, lockedCount, maxProjectsTotal)
|
||||
field.onChange({ ...field.value, maxProjects: clamped })
|
||||
}}
|
||||
disabled={isLoading || isSaving || !field.value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors.resumeProjects?.maxProjects && <p className="text-xs text-destructive">{errors.resumeProjects.maxProjects.message}</p>}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<Controller
|
||||
name="resumeProjects"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Project</TableHead>
|
||||
<TableHead className="w-[110px]">Base visible</TableHead>
|
||||
<TableHead className="w-[90px]">Locked</TableHead>
|
||||
<TableHead className="w-[140px]">AI selectable</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{profileProjects.map((project) => {
|
||||
const locked = Boolean(field.value?.lockedProjectIds.includes(project.id))
|
||||
const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id))
|
||||
const excluded = !locked && !aiSelectable
|
||||
|
||||
return (
|
||||
<TableRow key={project.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-0.5">
|
||||
<div className="font-medium">{project.name || project.id}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{[project.description, project.date].filter(Boolean).join(" · ")}
|
||||
{excluded ? " · Excluded" : ""}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">{project.isVisibleInBase ? "Yes" : "No"}</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked}
|
||||
disabled={isLoading || isSaving || !field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!field.value) return
|
||||
const isChecked = checked === true
|
||||
const lockedIds = field.value.lockedProjectIds.slice()
|
||||
const selectableIds = field.value.aiSelectableProjectIds.slice()
|
||||
|
||||
if (isChecked) {
|
||||
if (!lockedIds.includes(project.id)) lockedIds.push(project.id)
|
||||
const nextSelectable = selectableIds.filter((id) => id !== project.id)
|
||||
const minCap = lockedIds.length
|
||||
field.onChange({
|
||||
...field.value,
|
||||
lockedProjectIds: lockedIds,
|
||||
aiSelectableProjectIds: nextSelectable,
|
||||
maxProjects: Math.max(field.value.maxProjects, minCap),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const nextLocked = lockedIds.filter((id) => id !== project.id)
|
||||
if (!selectableIds.includes(project.id)) selectableIds.push(project.id)
|
||||
field.onChange({
|
||||
...field.value,
|
||||
lockedProjectIds: nextLocked,
|
||||
aiSelectableProjectIds: selectableIds,
|
||||
maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal),
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={locked ? true : aiSelectable}
|
||||
disabled={locked || isLoading || isSaving || !field.value}
|
||||
onCheckedChange={(checked) => {
|
||||
if (!field.value) return
|
||||
const isChecked = checked === true
|
||||
const selectableIds = field.value.aiSelectableProjectIds.slice()
|
||||
const nextSelectable = isChecked
|
||||
? selectableIds.includes(project.id)
|
||||
? selectableIds
|
||||
: [...selectableIds, project.id]
|
||||
: selectableIds.filter((id) => id !== project.id)
|
||||
field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable })
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
)
|
||||
}
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
} from '@server/services/resumeProjects.js';
|
||||
import { getProfile } from '@server/services/profile.js';
|
||||
import { getEffectiveSettings } from '@server/services/settings.js';
|
||||
import { listResumes, RxResumeCredentialsError } from '@server/services/rxresume-v4.js';
|
||||
import { getResume, listResumes, RxResumeCredentialsError } from '@server/services/rxresume-v4.js';
|
||||
|
||||
export const settingsRouter = Router();
|
||||
|
||||
@ -58,6 +58,10 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
promises.push(settingsRepo.setSetting('jobCompleteWebhookUrl', input.jobCompleteWebhookUrl ?? null));
|
||||
}
|
||||
|
||||
if ('rxresumeBaseResumeId' in input) {
|
||||
promises.push(settingsRepo.setSetting('rxresumeBaseResumeId', normalizeEnvInput(input.rxresumeBaseResumeId)));
|
||||
}
|
||||
|
||||
if ('resumeProjects' in input) {
|
||||
const resumeProjects = input.resumeProjects ?? null;
|
||||
|
||||
@ -65,13 +69,35 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
promises.push(settingsRepo.setSetting('resumeProjects', null));
|
||||
} else {
|
||||
promises.push((async () => {
|
||||
const rawProfile = await getProfile();
|
||||
const baseResumeId = 'rxresumeBaseResumeId' in input
|
||||
? normalizeEnvInput(input.rxresumeBaseResumeId)
|
||||
: await settingsRepo.getSetting('rxresumeBaseResumeId');
|
||||
|
||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||
let profile: Record<string, unknown> = {};
|
||||
|
||||
if (baseResumeId) {
|
||||
try {
|
||||
const resume = await getResume(baseResumeId);
|
||||
if (resume.data && typeof resume.data === 'object') {
|
||||
profile = resume.data as Record<string, unknown>;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
throw new Error('RxResume credentials missing while validating resume projects.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(profile).length === 0) {
|
||||
const rawProfile = await getProfile();
|
||||
|
||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||
}
|
||||
|
||||
profile = rawProfile as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const profile = rawProfile as Record<string, unknown>;
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((p) => p.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
@ -218,3 +244,30 @@ settingsRouter.get('/rx-resumes', async (_req: Request, res: Response) => {
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/settings/rx-resumes/:id/projects - Fetch project catalog from RxResume v4
|
||||
*/
|
||||
settingsRouter.get('/rx-resumes/:id/projects', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const resumeId = req.params.id;
|
||||
if (!resumeId) {
|
||||
res.status(400).json({ success: false, error: 'Resume id is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const resume = await getResume(resumeId);
|
||||
const profile = resume.data ?? {};
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
|
||||
res.json({ success: true, data: { projects: catalog } });
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
res.status(400).json({ success: false, error: error.message });
|
||||
return;
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`❌ Failed to fetch RxResume projects: ${message}`);
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@ -14,6 +14,7 @@ export type SettingKey = 'model'
|
||||
| 'pipelineWebhookUrl'
|
||||
| 'jobCompleteWebhookUrl'
|
||||
| 'resumeProjects'
|
||||
| 'rxresumeBaseResumeId'
|
||||
| 'ukvisajobsMaxJobs'
|
||||
| 'gradcrackerMaxJobsPerTerm'
|
||||
| 'searchTerms'
|
||||
|
||||
@ -3,19 +3,38 @@ import * as settingsRepo from '@server/repositories/settings.js';
|
||||
import { getEnvSettingsData } from './envSettings.js';
|
||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js';
|
||||
import { getProfile } from './profile.js';
|
||||
import { getResume, RxResumeCredentialsError } from './rxresume-v4.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().catch((error) => {
|
||||
const overrides = await settingsRepo.getAllSettings();
|
||||
|
||||
const rxresumeBaseResumeId = overrides.rxresumeBaseResumeId ?? null;
|
||||
let profile: Record<string, unknown> = {};
|
||||
|
||||
if (rxresumeBaseResumeId) {
|
||||
try {
|
||||
const resume = await getResume(rxresumeBaseResumeId);
|
||||
if (resume.data && typeof resume.data === 'object') {
|
||||
profile = resume.data as Record<string, unknown>;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
console.warn('RxResume credentials missing while loading base resume from settings.');
|
||||
} else {
|
||||
console.warn('Failed to load RxResume base resume for settings:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(profile).length === 0) {
|
||||
profile = await getProfile().catch((error) => {
|
||||
console.warn('Failed to load base resume profile for settings:', error);
|
||||
return {};
|
||||
}),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
const envSettings = await getEnvSettingsData(overrides);
|
||||
|
||||
@ -114,6 +133,7 @@ export async function getEffectiveSettings(): Promise<AppSettings> {
|
||||
defaultJobCompleteWebhookUrl,
|
||||
overrideJobCompleteWebhookUrl,
|
||||
...resumeProjectsData,
|
||||
rxresumeBaseResumeId,
|
||||
ukvisajobsMaxJobs,
|
||||
defaultUkvisajobsMaxJobs,
|
||||
overrideUkvisajobsMaxJobs,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const resumeProjectsSchema = z.object({
|
||||
maxProjects: z.number().int().min(0).max(100),
|
||||
maxProjects: z.number().int().min(1).max(100),
|
||||
lockedProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200),
|
||||
});
|
||||
@ -14,6 +14,7 @@ export const updateSettingsSchema = z.object({
|
||||
pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
||||
jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(),
|
||||
resumeProjects: resumeProjectsSchema.nullable().optional(),
|
||||
rxresumeBaseResumeId: z.string().trim().max(200).nullable().optional(),
|
||||
ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(1000).nullable().optional(),
|
||||
searchTerms: z.array(z.string().trim().min(1).max(200)).max(100).nullable().optional(),
|
||||
|
||||
@ -363,6 +363,7 @@ export interface AppSettings {
|
||||
resumeProjects: ResumeProjectsSettings;
|
||||
defaultResumeProjects: ResumeProjectsSettings;
|
||||
overrideResumeProjects: ResumeProjectsSettings | null;
|
||||
rxresumeBaseResumeId: string | null;
|
||||
ukvisajobsMaxJobs: number;
|
||||
defaultUkvisajobsMaxJobs: number;
|
||||
overrideUkvisajobsMaxJobs: number | null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user