diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 5322da3..7bc8e6f 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", @@ -1476,6 +1477,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", @@ -2141,6 +2148,67 @@ } } }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-separator": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", @@ -2359,6 +2427,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", diff --git a/orchestrator/package.json b/orchestrator/package.json index 1130faf..ca480a0 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -26,6 +26,7 @@ "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 9b3cae5..5fe2829 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -186,6 +186,7 @@ export async function updateSettings(update: { jobspyCountryIndeed?: string | null jobspySites?: string[] | null jobspyLinkedinFetchDescription?: boolean | null + rxResumeBaseResumeId?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', @@ -193,6 +194,12 @@ export async function updateSettings(update: { }); } +export async function getRxResumes(): Promise<{ id: string; name: string }[]> { + const data = await fetchApi<{ resumes: { id: string; name: string }[] }>('/settings/rx-resumes'); + return data.resumes; +} + + // Database API export async function clearDatabase(): Promise<{ message: string; diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 6859f06..febfb7e 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -22,6 +22,7 @@ import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSec import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection" import { SearchTermsSection } from "./settings/components/SearchTermsSection" import { UkvisajobsSection } from "./settings/components/UkvisajobsSection" +import { ReactiveResumeSection } from "./settings/components/ReactiveResumeSection" export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) @@ -41,6 +42,7 @@ export const SettingsPage: React.FC = () => { const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState(null) const [jobspySitesDraft, setJobspySitesDraft] = useState(null) const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState(null) + const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState(null) const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) const [statusesToClear, setStatusesToClear] = useState(['discovered']) @@ -69,6 +71,7 @@ export const SettingsPage: React.FC = () => { setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed) setJobspySitesDraft(data.overrideJobspySites) setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription) + setRxResumeBaseResumeIdDraft(data.rxResumeBaseResumeId) }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" @@ -163,7 +166,8 @@ export const SettingsPage: React.FC = () => { jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) || jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) || JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) || - jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) + jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) || + rxResumeBaseResumeIdDraft !== (settings.rxResumeBaseResumeId ?? null) ) }, [ settings, @@ -198,6 +202,7 @@ export const SettingsPage: React.FC = () => { overrideJobspyCountryIndeed, overrideJobspySites, overrideJobspyLinkedinFetchDescription, + rxResumeBaseResumeIdDraft, ]) const handleSave = async () => { @@ -222,6 +227,7 @@ export const SettingsPage: React.FC = () => { const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft + const rxResumeBaseResumeIdOverride = rxResumeBaseResumeIdDraft const updated = await api.updateSettings({ model: trimmed.length > 0 ? trimmed : null, modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null, @@ -239,6 +245,7 @@ export const SettingsPage: React.FC = () => { jobspyCountryIndeed: jobspyCountryIndeedOverride, jobspySites: jobspySitesOverride, jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride, + rxResumeBaseResumeId: rxResumeBaseResumeIdOverride, }) setSettings(updated) setModelDraft(updated.overrideModel ?? "") @@ -257,6 +264,7 @@ export const SettingsPage: React.FC = () => { setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed) setJobspySitesDraft(updated.overrideJobspySites) setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription) + setRxResumeBaseResumeIdDraft(updated.rxResumeBaseResumeId) toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" @@ -340,6 +348,7 @@ export const SettingsPage: React.FC = () => { jobspyCountryIndeed: null, jobspySites: null, jobspyLinkedinFetchDescription: null, + rxResumeBaseResumeId: null, }) setSettings(updated) setModelDraft("") @@ -358,6 +367,7 @@ export const SettingsPage: React.FC = () => { setJobspyCountryIndeedDraft(null) setJobspySitesDraft(null) setJobspyLinkedinFetchDescriptionDraft(null) + setRxResumeBaseResumeIdDraft(null) toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" @@ -471,6 +481,13 @@ export const SettingsPage: React.FC = () => { isLoading={isLoading} isSaving={isSaving} /> + void + hasRxResumeApiKey: boolean + isLoading: boolean + isSaving: boolean +} + +export const ReactiveResumeSection: React.FC = ({ + rxResumeBaseResumeIdDraft, + setRxResumeBaseResumeIdDraft, + hasRxResumeApiKey, + isLoading, + isSaving, +}) => { + const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]) + const [isFetchingResumes, setIsFetchingResumes] = useState(false) + const [fetchError, setFetchError] = useState(null) + + const fetchResumes = async () => { + if (!hasRxResumeApiKey) return + + setIsFetchingResumes(true) + setFetchError(null) + try { + const data = await api.getRxResumes() + setResumes(data) + } catch (error) { + setFetchError(error instanceof Error ? error.message : "Failed to fetch resumes") + } finally { + setIsFetchingResumes(false) + } + } + + useEffect(() => { + if (hasRxResumeApiKey) { + fetchResumes() + } + }, [hasRxResumeApiKey]) + + return ( + + + Reactive Resume + + +
+ {!hasRxResumeApiKey ? ( + + + API Key Missing + + RXRESUME_API_KEY is not configured in the server environment. Please add it to your .env file. + + + ) : ( + <> + + + API Key Configured + + Reactive Resume API integration is active. + + + +
+
+
Base Resume
+ +
+ + + + {fetchError && ( +
+ {fetchError} +
+ )} + +
+ The selected resume will be used as a template for tailoring. A temporary copy will be created during generation and deleted afterwards. +
+
+ + )} +
+
+
+ ) +} diff --git a/orchestrator/src/components/ui/alert.tsx b/orchestrator/src/components/ui/alert.tsx new file mode 100644 index 0000000..81e4f01 --- /dev/null +++ b/orchestrator/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/orchestrator/src/components/ui/select.tsx b/orchestrator/src/components/ui/select.tsx new file mode 100644 index 0000000..0c1f7c5 --- /dev/null +++ b/orchestrator/src/components/ui/select.tsx @@ -0,0 +1,159 @@ +"use client" + +import * as React from "react" +import * as SelectPrimitive from "@radix-ui/react-select" +import { Check, ChevronDown, ChevronUp } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + span]:line-clamp-1", + className + )} + {...props} + > + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectScrollUpButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName + +const SelectScrollDownButton = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +SelectScrollDownButton.displayName = + SelectPrimitive.ScrollDownButton.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = "popper", ...props }, ref) => ( + + + + + {children} + + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { + Select, + SelectGroup, + SelectValue, + SelectTrigger, + SelectContent, + SelectLabel, + SelectItem, + SelectSeparator, + SelectScrollUpButton, + SelectScrollDownButton, +} diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 796660e..eb7dda8 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -10,132 +10,145 @@ import { export const settingsRouter = Router(); +/** + * Helper to fetch all settings and their defaults + */ +async function getFullSettings() { + const overrideModel = await settingsRepo.getSetting('model'); + const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; + const model = overrideModel || defaultModel; + + // Specific AI models + const overrideModelScorer = await settingsRepo.getSetting('modelScorer'); + const modelScorer = overrideModelScorer || model; + + const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring'); + const modelTailoring = overrideModelTailoring || model; + + const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection'); + const modelProjectSelection = overrideModelProjectSelection || model; + + const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl'); + const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ''; + const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl; + + const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl'); + const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || ''; + const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl; + + const profile = await loadResumeProfile(); + const { catalog } = extractProjectsFromProfile(profile); + const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects'); + const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw }); + + const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs'); + const defaultUkvisajobsMaxJobs = 50; + const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null; + const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs; + + const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm'); + const defaultGradcrackerMaxJobsPerTerm = 50; + const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null; + const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm; + + const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms'); + const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer'; + const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean); + const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null; + const searchTerms = overrideSearchTerms ?? defaultSearchTerms; + + // JobSpy settings + const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation'); + const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK'; + const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation; + + const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted'); + const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10); + const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null; + const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted; + + const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld'); + const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10); + const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null; + const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld; + + const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed'); + const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK'; + const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed; + + const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites'); + const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean); + const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null; + const jobspySites = overrideJobspySites ?? defaultJobspySites; + + const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription'); + const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1'; + const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw + ? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1' + : null; + const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription; + + const rxResumeBaseResumeId = await settingsRepo.getSetting('rxResumeBaseResumeId'); + const hasRxResumeApiKey = !!process.env.RXRESUME_API_KEY; + + return { + model, + defaultModel, + overrideModel, + modelScorer, + overrideModelScorer, + modelTailoring, + overrideModelTailoring, + modelProjectSelection, + overrideModelProjectSelection, + pipelineWebhookUrl, + defaultPipelineWebhookUrl, + overridePipelineWebhookUrl, + jobCompleteWebhookUrl, + defaultJobCompleteWebhookUrl, + overrideJobCompleteWebhookUrl, + ...resumeProjectsData, + ukvisajobsMaxJobs, + defaultUkvisajobsMaxJobs, + overrideUkvisajobsMaxJobs, + gradcrackerMaxJobsPerTerm, + defaultGradcrackerMaxJobsPerTerm, + overrideGradcrackerMaxJobsPerTerm, + searchTerms, + defaultSearchTerms, + overrideSearchTerms, + jobspyLocation, + defaultJobspyLocation, + overrideJobspyLocation, + jobspyResultsWanted, + defaultJobspyResultsWanted, + overrideJobspyResultsWanted, + jobspyHoursOld, + defaultJobspyHoursOld, + overrideJobspyHoursOld, + jobspyCountryIndeed, + defaultJobspyCountryIndeed, + overrideJobspyCountryIndeed, + jobspySites, + defaultJobspySites, + overrideJobspySites, + jobspyLinkedinFetchDescription, + defaultJobspyLinkedinFetchDescription, + overrideJobspyLinkedinFetchDescription, + rxResumeBaseResumeId, + hasRxResumeApiKey, + }; +} + /** * GET /api/settings - Get app settings (effective + defaults) */ settingsRouter.get('/', async (_req: Request, res: Response) => { try { - const overrideModel = await settingsRepo.getSetting('model'); - const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; - const model = overrideModel || defaultModel; - - // Specific AI models - const overrideModelScorer = await settingsRepo.getSetting('modelScorer'); - const modelScorer = overrideModelScorer || model; - - const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring'); - const modelTailoring = overrideModelTailoring || model; - - const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection'); - const modelProjectSelection = overrideModelProjectSelection || model; - - const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl'); - const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ''; - const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl; - - const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl'); - const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || ''; - const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl; - - const profile = await loadResumeProfile(); - const { catalog } = extractProjectsFromProfile(profile); - const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects'); - const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw }); - - const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs'); - const defaultUkvisajobsMaxJobs = 50; - const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null; - const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs; - - const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm'); - const defaultGradcrackerMaxJobsPerTerm = 50; - const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null; - const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm; - - const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms'); - const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer'; - const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean); - const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null; - const searchTerms = overrideSearchTerms ?? defaultSearchTerms; - - // JobSpy settings (GET) - const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation'); - const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK'; - const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation; - - const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted'); - const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10); - const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null; - const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted; - - const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld'); - const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10); - const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null; - const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld; - - const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed'); - const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK'; - const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed; - - const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites'); - const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean); - const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null; - const jobspySites = overrideJobspySites ?? defaultJobspySites; - - const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription'); - const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1'; - const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw - ? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1' - : null; - const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription; - + const data = await getFullSettings(); res.json({ success: true, - data: { - model, - defaultModel, - overrideModel, - modelScorer, - overrideModelScorer, - modelTailoring, - overrideModelTailoring, - modelProjectSelection, - overrideModelProjectSelection, - pipelineWebhookUrl, - defaultPipelineWebhookUrl, - overridePipelineWebhookUrl, - jobCompleteWebhookUrl, - defaultJobCompleteWebhookUrl, - overrideJobCompleteWebhookUrl, - ...resumeProjectsData, - ukvisajobsMaxJobs, - defaultUkvisajobsMaxJobs, - overrideUkvisajobsMaxJobs, - gradcrackerMaxJobsPerTerm, - defaultGradcrackerMaxJobsPerTerm, - overrideGradcrackerMaxJobsPerTerm, - searchTerms, - defaultSearchTerms, - overrideSearchTerms, - jobspyLocation, - defaultJobspyLocation, - overrideJobspyLocation, - jobspyResultsWanted, - defaultJobspyResultsWanted, - overrideJobspyResultsWanted, - jobspyHoursOld, - defaultJobspyHoursOld, - overrideJobspyHoursOld, - jobspyCountryIndeed, - defaultJobspyCountryIndeed, - overrideJobspyCountryIndeed, - jobspySites, - defaultJobspySites, - overrideJobspySites, - jobspyLinkedinFetchDescription, - defaultJobspyLinkedinFetchDescription, - overrideJobspyLinkedinFetchDescription, - }, + data, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -164,6 +177,7 @@ const updateSettingsSchema = z.object({ jobspyCountryIndeed: z.string().trim().min(1).max(100).nullable().optional(), jobspySites: z.array(z.string().trim().min(1).max(50)).max(10).nullable().optional(), jobspyLinkedinFetchDescription: z.boolean().nullable().optional(), + rxResumeBaseResumeId: z.string().trim().min(1).max(200).nullable().optional(), }); /** @@ -263,127 +277,14 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { await settingsRepo.setSetting('jobspyLinkedinFetchDescription', value !== null ? (value ? '1' : '0') : null); } - const overrideModel = await settingsRepo.getSetting('model'); - const defaultModel = process.env.MODEL || 'openai/gpt-4o-mini'; - const model = overrideModel || defaultModel; - - const overrideModelScorer = await settingsRepo.getSetting('modelScorer'); - const modelScorer = overrideModelScorer || model; - - const overrideModelTailoring = await settingsRepo.getSetting('modelTailoring'); - const modelTailoring = overrideModelTailoring || model; - - const overrideModelProjectSelection = await settingsRepo.getSetting('modelProjectSelection'); - const modelProjectSelection = overrideModelProjectSelection || model; - - const overridePipelineWebhookUrl = await settingsRepo.getSetting('pipelineWebhookUrl'); - const defaultPipelineWebhookUrl = process.env.PIPELINE_WEBHOOK_URL || process.env.WEBHOOK_URL || ''; - const pipelineWebhookUrl = overridePipelineWebhookUrl || defaultPipelineWebhookUrl; - - const overrideJobCompleteWebhookUrl = await settingsRepo.getSetting('jobCompleteWebhookUrl'); - const defaultJobCompleteWebhookUrl = process.env.JOB_COMPLETE_WEBHOOK_URL || ''; - const jobCompleteWebhookUrl = overrideJobCompleteWebhookUrl || defaultJobCompleteWebhookUrl; - - const profile = await loadResumeProfile(); - const { catalog } = extractProjectsFromProfile(profile); - const overrideResumeProjectsRaw = await settingsRepo.getSetting('resumeProjects'); - const resumeProjectsData = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw }); - - const overrideUkvisajobsMaxJobsRaw = await settingsRepo.getSetting('ukvisajobsMaxJobs'); - const defaultUkvisajobsMaxJobs = 50; - const overrideUkvisajobsMaxJobs = overrideUkvisajobsMaxJobsRaw ? parseInt(overrideUkvisajobsMaxJobsRaw, 10) : null; - const ukvisajobsMaxJobs = overrideUkvisajobsMaxJobs ?? defaultUkvisajobsMaxJobs; - - const overrideGradcrackerMaxJobsPerTermRaw = await settingsRepo.getSetting('gradcrackerMaxJobsPerTerm'); - const defaultGradcrackerMaxJobsPerTerm = 50; - const overrideGradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTermRaw ? parseInt(overrideGradcrackerMaxJobsPerTermRaw, 10) : null; - const gradcrackerMaxJobsPerTerm = overrideGradcrackerMaxJobsPerTerm ?? defaultGradcrackerMaxJobsPerTerm; - - // Search terms - stored as JSON array, default from env var (pipe-separated) - const overrideSearchTermsRaw = await settingsRepo.getSetting('searchTerms'); - const defaultSearchTermsEnv = process.env.JOBSPY_SEARCH_TERMS || 'web developer'; - const defaultSearchTerms = defaultSearchTermsEnv.split('|').map(s => s.trim()).filter(Boolean); - const overrideSearchTerms = overrideSearchTermsRaw ? JSON.parse(overrideSearchTermsRaw) as string[] : null; - const searchTerms = overrideSearchTerms ?? defaultSearchTerms; - - // JobSpy settings (re-fetch to update response) - const overrideJobspyLocation = await settingsRepo.getSetting('jobspyLocation'); - const defaultJobspyLocation = process.env.JOBSPY_LOCATION || 'UK'; - const jobspyLocation = overrideJobspyLocation || defaultJobspyLocation; - - const overrideJobspyResultsWantedRaw = await settingsRepo.getSetting('jobspyResultsWanted'); - const defaultJobspyResultsWanted = parseInt(process.env.JOBSPY_RESULTS_WANTED || '200', 10); - const overrideJobspyResultsWanted = overrideJobspyResultsWantedRaw ? parseInt(overrideJobspyResultsWantedRaw, 10) : null; - const jobspyResultsWanted = overrideJobspyResultsWanted ?? defaultJobspyResultsWanted; - - const overrideJobspyHoursOldRaw = await settingsRepo.getSetting('jobspyHoursOld'); - const defaultJobspyHoursOld = parseInt(process.env.JOBSPY_HOURS_OLD || '72', 10); - const overrideJobspyHoursOld = overrideJobspyHoursOldRaw ? parseInt(overrideJobspyHoursOldRaw, 10) : null; - const jobspyHoursOld = overrideJobspyHoursOld ?? defaultJobspyHoursOld; - - const overrideJobspyCountryIndeed = await settingsRepo.getSetting('jobspyCountryIndeed'); - const defaultJobspyCountryIndeed = process.env.JOBSPY_COUNTRY_INDEED || 'UK'; - const jobspyCountryIndeed = overrideJobspyCountryIndeed || defaultJobspyCountryIndeed; - - const overrideJobspySitesRaw = await settingsRepo.getSetting('jobspySites'); - const defaultJobspySites = (process.env.JOBSPY_SITES || 'indeed,linkedin').split(',').map(s => s.trim()).filter(Boolean); - const overrideJobspySites = overrideJobspySitesRaw ? JSON.parse(overrideJobspySitesRaw) as string[] : null; - const jobspySites = overrideJobspySites ?? defaultJobspySites; - - const overrideJobspyLinkedinFetchDescriptionRaw = await settingsRepo.getSetting('jobspyLinkedinFetchDescription'); - const defaultJobspyLinkedinFetchDescription = (process.env.JOBSPY_LINKEDIN_FETCH_DESCRIPTION || '1') === '1'; - const overrideJobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescriptionRaw - ? overrideJobspyLinkedinFetchDescriptionRaw === 'true' || overrideJobspyLinkedinFetchDescriptionRaw === '1' - : null; - const jobspyLinkedinFetchDescription = overrideJobspyLinkedinFetchDescription ?? defaultJobspyLinkedinFetchDescription; + if ('rxResumeBaseResumeId' in input) { + await settingsRepo.setSetting('rxResumeBaseResumeId', input.rxResumeBaseResumeId ?? null); + } + const data = await getFullSettings(); res.json({ success: true, - data: { - model, - defaultModel, - overrideModel, - modelScorer, - overrideModelScorer, - modelTailoring, - overrideModelTailoring, - modelProjectSelection, - overrideModelProjectSelection, - pipelineWebhookUrl, - defaultPipelineWebhookUrl, - overridePipelineWebhookUrl, - jobCompleteWebhookUrl, - defaultJobCompleteWebhookUrl, - overrideJobCompleteWebhookUrl, - ...resumeProjectsData, - ukvisajobsMaxJobs, - defaultUkvisajobsMaxJobs, - overrideUkvisajobsMaxJobs, - gradcrackerMaxJobsPerTerm, - defaultGradcrackerMaxJobsPerTerm, - overrideGradcrackerMaxJobsPerTerm, - searchTerms, - defaultSearchTerms, - overrideSearchTerms, - jobspyLocation, - defaultJobspyLocation, - overrideJobspyLocation, - jobspyResultsWanted, - defaultJobspyResultsWanted, - overrideJobspyResultsWanted, - jobspyHoursOld, - defaultJobspyHoursOld, - overrideJobspyHoursOld, - jobspyCountryIndeed, - defaultJobspyCountryIndeed, - overrideJobspyCountryIndeed, - jobspySites, - defaultJobspySites, - overrideJobspySites, - jobspyLinkedinFetchDescription, - defaultJobspyLinkedinFetchDescription, - overrideJobspyLinkedinFetchDescription, - }, + data, }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -392,3 +293,40 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { res.status(400).json({ success: false, error: message }); } }); + +/** + * GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume API + */ +settingsRouter.get('/rx-resumes', async (_req: Request, res: Response) => { + try { + const apiKey = process.env.RXRESUME_API_KEY; + if (!apiKey) { + return res.status(400).json({ success: false, error: 'RXRESUME_API_KEY not configured in environment' }); + } + + const baseUrl = process.env.RXRESUME_URL || 'https://rxresu.me'; + // Remove trailing slash if present + const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + + console.log(`🔍 Fetching resumes from Reactive Resume at ${cleanBaseUrl}/api/resume...`); + + const response = await fetch(`${cleanBaseUrl}/api/resume`, { + headers: { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`); + } + + const resumes = await response.json(); + res.json({ success: true, resumes }); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`❌ Failed to fetch Reactive Resumes: ${message}`); + res.status(500).json({ success: false, error: message }); + } +}); diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index b31fae6..60884be 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -25,6 +25,7 @@ import * as settingsRepo from '../repositories/settings.js'; import { progressHelpers, resetProgress, updateProgress } from './progress.js'; import type { CreateJobInput, Job, JobSource, PipelineConfig } from '../../shared/types.js'; import { getDataDir } from '../config/dataDir.js'; +import { getResume } from '../services/rxresume.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DEFAULT_PROFILE_PATH = join(__dirname, '../../../../resume-generator/base.json'); @@ -553,10 +554,16 @@ export function getPipelineStatus(): { isRunning: boolean } { */ async function loadProfile(profilePath: string): Promise> { try { + const rxResumeBaseResumeId = await settingsRepo.getSetting('rxResumeBaseResumeId'); + if (rxResumeBaseResumeId) { + const resume = await getResume(rxResumeBaseResumeId); + return resume.data as Record; + } + const content = await readFile(profilePath, 'utf-8'); return JSON.parse(content); } catch (error) { - console.warn('Failed to load profile, using empty object'); + console.warn(`Failed to load profile from ${profilePath}, using empty object`, error); return {}; } } diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 088f860..a7de290 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -23,6 +23,7 @@ export type SettingKey = 'model' | 'jobspyCountryIndeed' | 'jobspySites' | 'jobspyLinkedinFetchDescription' + | 'rxResumeBaseResumeId' export async function getSetting(key: SettingKey): Promise { const [row] = await db.select().from(settings).where(eq(settings.key, key)) diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 9af293e..63c45b4 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -1,23 +1,17 @@ /** - * Service for generating PDF resumes using RXResume. - * Wraps the existing Python rxresume_automation.py script. + * Service for generating PDF resumes using Reactive Resume API. */ -import { spawn } from 'child_process'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; -import { readFile, writeFile, mkdir, access, unlink } from 'fs/promises'; +import { join } from 'path'; +import { writeFile, mkdir, access } from 'fs/promises'; import { existsSync } from 'fs'; import { getSetting } from '../repositories/settings.js'; import { pickProjectIdsForJob } from './projectSelection.js'; import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js'; import { getDataDir } from '../config/dataDir.js'; +import { getResume, importResume, exportResumePdf, deleteResume } from './rxresume.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); - -// Paths - can be overridden via env for Docker -const RESUME_GEN_DIR = process.env.RESUME_GEN_DIR || join(__dirname, '../../../../resume-generator'); const OUTPUT_DIR = join(getDataDir(), 'pdfs'); export interface PdfResult { @@ -33,73 +27,76 @@ export interface TailoredPdfContent { } /** - * Generate a tailored PDF resume for a job. - * - * @param jobId - Unique job identifier - * @param tailoredContent - Content to inject (summary, headline, skills) - * @param jobDescription - Job description (for project selection) - * @param baseResumePath - Optional path to base JSON - * @param selectedProjectIds - Optional overrides + * Generate a tailored PDF resume for a job using Reactive Resume API. */ export async function generatePdf( jobId: string, tailoredContent: TailoredPdfContent, jobDescription: string, - baseResumePath?: string, + _baseResumePath?: string, // Deprecated/ignored when using API selectedProjectIds?: string | null ): Promise { - console.log(`📄 Generating PDF for job ${jobId}...`); - - const resumeJsonPath = baseResumePath || join(RESUME_GEN_DIR, 'base.json'); - + console.log(`📄 Generating PDF for job ${jobId} using Reactive Resume API...`); + + let tempResumeId: string | null = null; + try { + // 1. Get base resume ID from settings + const baseResumeId = await getSetting('rxResumeBaseResumeId'); + if (!baseResumeId) { + throw new Error('rxResumeBaseResumeId not configured in settings. Please select a base resume in settings first.'); + } + // Ensure output directory exists if (!existsSync(OUTPUT_DIR)) { await mkdir(OUTPUT_DIR, { recursive: true }); } - - // Read base resume - const baseResume = JSON.parse(await readFile(resumeJsonPath, 'utf-8')); - + + // 2. Fetch base resume data + console.log(` Fetching base resume ${baseResumeId}...`); + const baseResumeResponse = await getResume(baseResumeId); + const resumeData = baseResumeResponse.data; + + // 3. Apply tailoring + // Inject tailored summary if (tailoredContent.summary) { - if (baseResume.sections?.summary) { - baseResume.sections.summary.content = tailoredContent.summary; - } else if (baseResume.basics?.summary) { - baseResume.basics.summary = tailoredContent.summary; + if (resumeData.sections?.summary) { + resumeData.sections.summary.content = tailoredContent.summary; + } else if (resumeData.basics?.summary) { + resumeData.basics.summary = tailoredContent.summary; } } // Inject tailored headline if (tailoredContent.headline) { - if (baseResume.basics) { - // Support both standard JSON Resume 'label' and RxResume 'headline' - baseResume.basics.headline = tailoredContent.headline; - baseResume.basics.label = tailoredContent.headline; + if (resumeData.basics) { + resumeData.basics.headline = tailoredContent.headline; + resumeData.basics.label = tailoredContent.headline; } } // Inject tailored skills if (tailoredContent.skills) { - const newSkills = Array.isArray(tailoredContent.skills) - ? tailoredContent.skills - : typeof tailoredContent.skills === 'string' - ? JSON.parse(tailoredContent.skills) + const newSkills = Array.isArray(tailoredContent.skills) + ? tailoredContent.skills + : typeof tailoredContent.skills === 'string' + ? JSON.parse(tailoredContent.skills) : null; - if (newSkills && baseResume.sections?.skills) { - baseResume.sections.skills.items = newSkills; + if (newSkills && resumeData.sections?.skills) { + resumeData.sections.skills.items = newSkills; } } - // Select projects (manual override OR locked + AI-picked) and set visibility for RXResume + // 4. Select projects and set visibility try { let selectedSet: Set; if (selectedProjectIds) { selectedSet = new Set(selectedProjectIds.split(',').map(s => s.trim()).filter(Boolean)); } else { - const { catalog, selectionItems } = extractProjectsFromProfile(baseResume); + const { catalog, selectionItems } = extractProjectsFromProfile(resumeData); const overrideResumeProjectsRaw = await getSetting('resumeProjects'); const { resumeProjects } = resolveResumeProjectsSettings({ catalog, overrideRaw: overrideResumeProjectsRaw }); @@ -117,7 +114,7 @@ export async function generatePdf( selectedSet = new Set([...locked, ...picked]); } - const projectsSection = (baseResume as any)?.sections?.projects; + const projectsSection = resumeData.sections?.projects; const projectItems = projectsSection?.items; if (Array.isArray(projectItems)) { for (const item of projectItems) { @@ -131,74 +128,61 @@ export async function generatePdf( } catch (err) { console.warn(` ⚠️ Project visibility step failed for job ${jobId}:`, err); } - - // Write modified resume to temp file - const tempResumePath = join(RESUME_GEN_DIR, `temp_resume_${jobId}.json`); - await writeFile(tempResumePath, JSON.stringify(baseResume, null, 2)); - - // Generate PDF using Python script - output directly to our data folder + + // 5. Import as temporary resume + console.log(` Importing temporary resume for job ${jobId}...`); + const timestamp = new Date().getTime(); + const tempName = `[TEMP] ${resumeData.basics?.name || 'Resume'} - ${jobId.slice(0, 8)} (${timestamp})`; + + tempResumeId = await importResume({ + name: tempName, + slug: `temp-${jobId}-${timestamp}`, + data: resumeData, + }); + + if (!tempResumeId) { + throw new Error('Failed to get ID for imported resume'); + } + + // 6. Export as PDF + console.log(` Printing PDF...`); + const pdfUrl = await exportResumePdf(tempResumeId); + + if (!pdfUrl) { + throw new Error('Reactive Resume did not return a PDF URL'); + } + + // 7. Download PDF const outputFilename = `resume_${jobId}.pdf`; const outputPath = join(OUTPUT_DIR, outputFilename); - // Ensure regeneration overwrites the old file if it exists. - try { - await unlink(outputPath); - } catch { - // Ignore if it doesn't exist or cannot be removed. + console.log(` Downloading PDF from ${pdfUrl}...`); + const pdfResponse = await fetch(pdfUrl); + if (!pdfResponse.ok) { + throw new Error(`Failed to download PDF (${pdfResponse.status}): ${pdfResponse.statusText}`); } - - await runPythonPdfGenerator(tempResumePath, outputFilename, OUTPUT_DIR); - - // Cleanup temp file - try { - const { unlink } = await import('fs/promises'); - await unlink(tempResumePath); - } catch { - // Ignore cleanup errors - } - + + const buffer = await pdfResponse.arrayBuffer(); + await writeFile(outputPath, Buffer.from(buffer)); + console.log(`✅ PDF generated: ${outputPath}`); + return { success: true, pdfPath: outputPath }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`❌ PDF generation failed: ${message}`); return { success: false, error: message }; - } -} - -/** - * Run the Python RXResume automation script. - */ -async function runPythonPdfGenerator( - jsonPath: string, - outputFilename: string, - outputDir: string -): Promise { - return new Promise((resolve, reject) => { - // Use the virtual environment's Python (or system python in Docker) - const pythonPath = process.env.PYTHON_PATH || join(RESUME_GEN_DIR, '.venv', 'bin', 'python'); - - const child = spawn(pythonPath, ['rxresume_automation.py'], { - cwd: RESUME_GEN_DIR, - env: { - ...process.env, - RESUME_JSON_PATH: jsonPath, - OUTPUT_FILENAME: outputFilename, - OUTPUT_DIR: outputDir, - }, - stdio: 'inherit', - }); - - child.on('close', (code) => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Python script exited with code ${code}`)); + } finally { + // 8. Cleanup temp resume + if (tempResumeId) { + try { + console.log(` Cleaning up temporary resume ${tempResumeId}...`); + await deleteResume(tempResumeId); + } catch (cleanupError) { + console.warn(` ⚠️ Failed to delete temporary resume ${tempResumeId}:`, cleanupError); } - }); - - child.on('error', reject); - }); + } + } } /** diff --git a/orchestrator/src/server/services/resumeProjects.ts b/orchestrator/src/server/services/resumeProjects.ts index e97bad3..a609ae1 100644 --- a/orchestrator/src/server/services/resumeProjects.ts +++ b/orchestrator/src/server/services/resumeProjects.ts @@ -1,7 +1,8 @@ -import { readFile } from 'fs/promises'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; +import { getSetting } from '../repositories/settings.js'; +import { getResume } from './rxresume.js'; import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from '../../shared/types.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -12,8 +13,30 @@ export const DEFAULT_RESUME_PROFILE_PATH = type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { summaryText: string }; export async function loadResumeProfile(profilePath: string = DEFAULT_RESUME_PROFILE_PATH): Promise { - const content = await readFile(profilePath, 'utf-8'); - return JSON.parse(content) as unknown; + try { + const rxResumeBaseResumeId = await getSetting('rxResumeBaseResumeId'); + if (rxResumeBaseResumeId) { + const resume = await getResume(rxResumeBaseResumeId); + return resume.data; + } + + const { readFile } = await import('fs/promises'); + const content = await readFile(profilePath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + console.warn(`Failed to load profile, using fallback if possible`, error); + // If Reactive Resume failed but we have a path, try reading file + if (profilePath) { + try { + const { readFile } = await import('fs/promises'); + const content = await readFile(profilePath, 'utf-8'); + return JSON.parse(content); + } catch (innerError) { + // ignore + } + } + return {}; + } } export function extractProjectsFromProfile(profile: unknown): { diff --git a/orchestrator/src/server/services/rxresume.ts b/orchestrator/src/server/services/rxresume.ts new file mode 100644 index 0000000..ba0560f --- /dev/null +++ b/orchestrator/src/server/services/rxresume.ts @@ -0,0 +1,85 @@ +/** + * Service for interacting with the Reactive Resume API. + */ + +export interface RxResumeResponse { + id: string; + name: string; + slug: string; + data: any; + [key: string]: any; +} + +/** + * Generic fetch helper for Reactive Resume API + */ +export async function fetchRxResume(path: string, options: RequestInit = {}): Promise { + const apiKey = process.env.RXRESUME_API_KEY; + if (!apiKey) { + throw new Error('RXRESUME_API_KEY not configured in environment'); + } + + const baseUrl = process.env.RXRESUME_URL || 'https://rxresu.me'; + const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + + // The API endpoints are at /api/* + const url = `${cleanBaseUrl}/api${path}`; + + const headers = { + 'x-api-key': apiKey, + 'Content-Type': 'application/json', + ...(options.headers || {}), + } as Record; + + const response = await fetch(url, { + ...options, + headers, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: response.statusText })); + throw new Error(`Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`); + } + + // Handle cases where the response might not be JSON (though usually it is) + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } + return response.text(); +} + +/** + * Fetch a resume by its ID. + */ +export async function getResume(id: string): Promise { + return fetchRxResume(`/resume/${id}`); +} + +/** + * Import a resume. + */ +export async function importResume(payload: { name: string; slug: string; data: any }): Promise { + const result = await fetchRxResume('/resume/import', { + method: 'POST', + body: JSON.stringify(payload), + }); + + // Reactive Resume returns the full resume object on import in v4+, or just ID in v5. + return typeof result === 'string' ? result : result.id; +} + +/** + * Delete a resume. + */ +export async function deleteResume(id: string): Promise { + await fetchRxResume(`/resume/${id}`, { method: 'DELETE' }); +} + +/** + * Export a resume as PDF. Returns the URL. + */ +export async function exportResumePdf(id: string): Promise { + const result = await fetchRxResume(`/printer/resume/${id}/pdf`); + return result.url; +} diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 7502ddc..ebc2ba2 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -275,7 +275,7 @@ export interface AppSettings { overrideModelTailoring: string | null; modelProjectSelection: string; // resolved overrideModelProjectSelection: string | null; - + pipelineWebhookUrl: string; defaultPipelineWebhookUrl: string; overridePipelineWebhookUrl: string | null; @@ -313,4 +313,6 @@ export interface AppSettings { jobspyLinkedinFetchDescription: boolean; defaultJobspyLinkedinFetchDescription: boolean; overrideJobspyLinkedinFetchDescription: boolean | null; + rxResumeBaseResumeId: string | null; + hasRxResumeApiKey: boolean; }