settings page can pull and save resume details from v4 api template

This commit is contained in:
DaKheera47 2026-01-23 11:06:25 +00:00
parent 4798846483
commit 7a358db317
11 changed files with 386 additions and 405 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,7 @@ export type SettingKey = 'model'
| 'pipelineWebhookUrl'
| 'jobCompleteWebhookUrl'
| 'resumeProjects'
| 'rxresumeBaseResumeId'
| 'ukvisajobsMaxJobs'
| 'gradcrackerMaxJobsPerTerm'
| 'searchTerms'

View File

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

View File

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

View File

@ -363,6 +363,7 @@ export interface AppSettings {
resumeProjects: ResumeProjectsSettings;
defaultResumeProjects: ResumeProjectsSettings;
overrideResumeProjects: ResumeProjectsSettings | null;
rxresumeBaseResumeId: string | null;
ukvisajobsMaxJobs: number;
defaultUkvisajobsMaxJobs: number;
overrideUkvisajobsMaxJobs: number | null;