diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 69a6417..f1dafcf 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -247,6 +247,13 @@ export async function getRxResumes(): Promise<{ id: string; name: string }[]> { return data.resumes; } +export async function getRxResumeProjects(resumeId: string): Promise { + const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>( + `/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects` + ); + return data.projects; +} + // Database API export async function clearDatabase(): Promise<{ diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index c144a64..46e0474 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -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({ valid: false, message: null, @@ -34,34 +31,11 @@ export const OnboardingGate: React.FC = () => { message: null, checked: false, }) - const [resumeValidation, setResumeValidation] = useState({ - valid: false, - message: null, - checked: false, - }) const [currentStep, setCurrentStep] = useState(null) const [openrouterApiKey, setOpenrouterApiKey] = useState("") const [rxresumeEmail, setRxresumeEmail] = useState("") const [rxresumePassword, setRxresumePassword] = useState("") - const [resumeFile, setResumeFile] = useState(null) - const fileInputRef = useRef(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 => { - 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 = () => { - + {steps.map((step, index) => { const isActive = step.id === currentStep const isComplete = step.complete @@ -439,30 +356,6 @@ export const OnboardingGate: React.FC = () => { - -
-

Upload your resume JSON

-

Use the JSON export you downloaded from v4.rxresu.me.

-
-
-
- - setResumeFile(event.target.files?.[0] ?? null)} - disabled={isUploadingResume} - /> - {resumeFileName && ( -

Selected: {resumeFileName}

- )} -
-
-
diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 1df8f43..a286d4a 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -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 = (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(['discovered']) + const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState(null) + const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState(null) + const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] = useState(false) const methods = useForm({ 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} /> - { + setRxResumeBaseResumeIdDraft(value) + setValue("rxresumeBaseResumeId", value, { shouldDirty: true }) + }} + hasRxResumeAccess={hasRxResumeAccess} + profileProjects={effectiveProfileProjects} lockedCount={lockedCount} - maxProjectsTotal={maxProjectsTotal} + maxProjectsTotal={effectiveMaxProjectsTotal} + isProjectsLoading={isFetchingRxResumeProjects} isLoading={isLoading} isSaving={isSaving} /> diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index 30ea1b9..68ff0c8 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -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 = ({ rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft, - hasRxResumeApiKey, + hasRxResumeAccess, + profileProjects, + lockedCount, + maxProjectsTotal, + isProjectsLoading, isLoading, isSaving, }) => { + const { control, formState: { errors } } = useFormContext() const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]) const [isFetchingResumes, setIsFetchingResumes] = useState(false) const [fetchError, setFetchError] = useState(null) const fetchResumes = async () => { - if (!hasRxResumeApiKey) return + if (!hasRxResumeAccess) return setIsFetchingResumes(true) setFetchError(null) @@ -42,10 +60,10 @@ export const ReactiveResumeSection: React.FC = ({ } useEffect(() => { - if (hasRxResumeApiKey) { + if (hasRxResumeAccess) { fetchResumes() } - }, [hasRxResumeApiKey]) + }, [hasRxResumeAccess]) return ( @@ -54,21 +72,21 @@ export const ReactiveResumeSection: React.FC = ({
- {!hasRxResumeApiKey ? ( + {!hasRxResumeAccess ? ( - API Key Missing + RxResume Access Missing - RXRESUME_API_KEY is not configured in the server environment. Please add it to your .env file. + Configure RxResume credentials in settings (email + password) or set RXRESUME_API_KEY to enable access. ) : ( <> - API Key Configured + RxResume Access Ready - Reactive Resume API integration is active. + Reactive Resume access is active. @@ -112,9 +130,141 @@ export const ReactiveResumeSection: React.FC = ({ )}
- 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.
+ + + +
+ {!rxResumeBaseResumeIdDraft ? ( +
+ Choose a PDF to configure resume projects. +
+ ) : ( + <> +
+
+ Max projects to choose +
+ ( + { + 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 && ( +

+ {errors.resumeProjects.maxProjects.message} +

+ )} +
+ + ( + + + + Project + Visible in template + Must Include + AI selectable + + + + + {profileProjects.map((project) => { + const locked = Boolean(field.value?.lockedProjectIds.includes(project.id)) + const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id)) + + return ( + + +
+
{project.name || project.id}
+
+ {[project.description, project.date].filter(Boolean).join(" - ")} +
+
+
+ {project.isVisibleInBase ? "Yes" : "No"} + + { + 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), + }) + }} + /> + + + { + 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 }) + }} + /> + +
+ ) + })} +
+
+ )} + /> + + )} +
)}
diff --git a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx deleted file mode 100644 index c2f1aef..0000000 --- a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx +++ /dev/null @@ -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({ - defaultValues: { - resumeProjects: initialDraft - } - }) - const watched = methods.watch() - const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0 - - return ( - - - - - - ) -} - - -describe("ResumeProjectsSection", () => { - it("clamps max projects to the locked count", async () => { - render( - - ) - - 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( - - ) - - 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) - }) -}) diff --git a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx deleted file mode 100644 index 92aad8f..0000000 --- a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx +++ /dev/null @@ -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 = ({ - profileProjects, - lockedCount, - maxProjectsTotal, - isLoading, - isSaving, -}) => { - const { control, formState: { errors } } = useFormContext() - - return ( - - - Resume Projects - - -
-
-
Max projects included
- ( - { - 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 &&

{errors.resumeProjects.maxProjects.message}

} -
- AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length} -
-
- - - - ( - - - - Project - Base visible - Locked - AI selectable - - - - {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 ( - - -
-
{project.name || project.id}
-
- {[project.description, project.date].filter(Boolean).join(" · ")} - {excluded ? " · Excluded" : ""} -
-
-
- {project.isVisibleInBase ? "Yes" : "No"} - - { - 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), - }) - }} - /> - - - { - 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 }) - }} - /> - -
- ) - })} -
-
- )} - /> -
-
-
- ) -} - diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index c299be1..ddfef40 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -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 = {}; + + if (baseResumeId) { + try { + const resume = await getResume(baseResumeId); + if (resume.data && typeof resume.data === 'object') { + profile = resume.data as Record; + } + } 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; } - const profile = rawProfile as Record; 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 }); + } +}); diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 6f98678..3c29152 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -14,6 +14,7 @@ export type SettingKey = 'model' | 'pipelineWebhookUrl' | 'jobCompleteWebhookUrl' | 'resumeProjects' + | 'rxresumeBaseResumeId' | 'ukvisajobsMaxJobs' | 'gradcrackerMaxJobsPerTerm' | 'searchTerms' diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index 907f6ce..5644a94 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -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 { - // 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 = {}; + + if (rxresumeBaseResumeId) { + try { + const resume = await getResume(rxresumeBaseResumeId); + if (resume.data && typeof resume.data === 'object') { + profile = resume.data as Record; + } + } 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 { defaultJobCompleteWebhookUrl, overrideJobCompleteWebhookUrl, ...resumeProjectsData, + rxresumeBaseResumeId, ukvisajobsMaxJobs, defaultUkvisajobsMaxJobs, overrideUkvisajobsMaxJobs, diff --git a/orchestrator/src/shared/settings-schema.ts b/orchestrator/src/shared/settings-schema.ts index 4339eb7..5801642 100644 --- a/orchestrator/src/shared/settings-schema.ts +++ b/orchestrator/src/shared/settings-schema.ts @@ -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(), diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 440d424..049a787 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -363,6 +363,7 @@ export interface AppSettings { resumeProjects: ResumeProjectsSettings; defaultResumeProjects: ResumeProjectsSettings; overrideResumeProjects: ResumeProjectsSettings | null; + rxresumeBaseResumeId: string | null; ukvisajobsMaxJobs: number; defaultUkvisajobsMaxJobs: number; overrideUkvisajobsMaxJobs: number | null;