api implemented

This commit is contained in:
DaKheera47 2026-01-20 12:02:58 +00:00
parent 97984be84f
commit 47fd4a0959
14 changed files with 841 additions and 343 deletions

View File

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

View File

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

View File

@ -186,6 +186,7 @@ export async function updateSettings(update: {
jobspyCountryIndeed?: string | null
jobspySites?: string[] | null
jobspyLinkedinFetchDescription?: boolean | null
rxResumeBaseResumeId?: string | null
}): Promise<AppSettings> {
return fetchApi<AppSettings>('/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;

View File

@ -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<AppSettings | null>(null)
@ -41,6 +42,7 @@ export const SettingsPage: React.FC = () => {
const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState<string | null>(null)
const [jobspySitesDraft, setJobspySitesDraft] = useState<string[] | null>(null)
const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState<boolean | null>(null)
const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState<string | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [statusesToClear, setStatusesToClear] = useState<JobStatus[]>(['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}
/>
<ReactiveResumeSection
rxResumeBaseResumeIdDraft={rxResumeBaseResumeIdDraft}
setRxResumeBaseResumeIdDraft={setRxResumeBaseResumeIdDraft}
hasRxResumeApiKey={settings?.hasRxResumeApiKey ?? false}
isLoading={isLoading}
isSaving={isSaving}
/>
<DangerZoneSection
statusesToClear={statusesToClear}
toggleStatusToClear={toggleStatusToClear}

View File

@ -0,0 +1,124 @@
import React, { useEffect, useState } from "react"
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import * as api from "../../../api"
type ReactiveResumeSectionProps = {
rxResumeBaseResumeIdDraft: string | null
setRxResumeBaseResumeIdDraft: (value: string | null) => void
hasRxResumeApiKey: boolean
isLoading: boolean
isSaving: boolean
}
export const ReactiveResumeSection: React.FC<ReactiveResumeSectionProps> = ({
rxResumeBaseResumeIdDraft,
setRxResumeBaseResumeIdDraft,
hasRxResumeApiKey,
isLoading,
isSaving,
}) => {
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
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 (
<AccordionItem value="reactive-resume" className="border rounded-lg px-4">
<AccordionTrigger className="hover:no-underline py-4">
<span className="text-base font-semibold">Reactive Resume</span>
</AccordionTrigger>
<AccordionContent className="pb-4">
<div className="space-y-4">
{!hasRxResumeApiKey ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertTitle>API Key Missing</AlertTitle>
<AlertDescription>
<code>RXRESUME_API_KEY</code> is not configured in the server environment. Please add it to your <code>.env</code> file.
</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>
<AlertDescription className="text-green-700 dark:text-green-400">
Reactive Resume API integration is active.
</AlertDescription>
</Alert>
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="text-sm font-medium">Base Resume</div>
<Button
variant="ghost"
size="sm"
onClick={fetchResumes}
disabled={isFetchingResumes || isLoading || isSaving}
className="h-8 px-2"
>
<RefreshCw className={`h-3 w-3 mr-1 ${isFetchingResumes ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
<Select
value={rxResumeBaseResumeIdDraft || "none"}
onValueChange={(value: string) => setRxResumeBaseResumeIdDraft(value === "none" ? null : value)}
disabled={isLoading || isSaving || isFetchingResumes}
>
<SelectTrigger>
<SelectValue placeholder="Select a base resume..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None (Fallback to local base.json)</SelectItem>
{resumes.map((resume) => (
<SelectItem key={resume.id} value={resume.id}>
{resume.name}
</SelectItem>
))}
</SelectContent>
</Select>
{fetchError && (
<div className="text-xs text-destructive mt-1">
{fetchError}
</div>
)}
<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.
</div>
</div>
</>
)}
</div>
</AccordionContent>
</AccordionItem>
)
}

View File

@ -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<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -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<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton >
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

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

View File

@ -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<Record<string, unknown>> {
try {
const rxResumeBaseResumeId = await settingsRepo.getSetting('rxResumeBaseResumeId');
if (rxResumeBaseResumeId) {
const resume = await getResume(rxResumeBaseResumeId);
return resume.data as Record<string, unknown>;
}
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 {};
}
}

View File

@ -23,6 +23,7 @@ export type SettingKey = 'model'
| 'jobspyCountryIndeed'
| 'jobspySites'
| 'jobspyLinkedinFetchDescription'
| 'rxResumeBaseResumeId'
export async function getSetting(key: SettingKey): Promise<string | null> {
const [row] = await db.select().from(settings).where(eq(settings.key, key))

View File

@ -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<PdfResult> {
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<string>;
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<void> {
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);
});
}
}
}
/**

View File

@ -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<unknown> {
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): {

View File

@ -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<any> {
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<string, string>;
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<RxResumeResponse> {
return fetchRxResume(`/resume/${id}`);
}
/**
* Import a resume.
*/
export async function importResume(payload: { name: string; slug: string; data: any }): Promise<string> {
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<void> {
await fetchRxResume(`/resume/${id}`, { method: 'DELETE' });
}
/**
* Export a resume as PDF. Returns the URL.
*/
export async function exportResumePdf(id: string): Promise<string> {
const result = await fetchRxResume(`/printer/resume/${id}/pdf`);
return result.url;
}

View File

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