From 47fd4a0959743659647ef90e335ccf562b229e7e Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 12:02:58 +0000 Subject: [PATCH 01/18] api implemented --- orchestrator/package-lock.json | 91 ++++ orchestrator/package.json | 1 + orchestrator/src/client/api/client.ts | 7 + .../src/client/pages/SettingsPage.tsx | 19 +- .../components/ReactiveResumeSection.tsx | 124 ++++++ orchestrator/src/components/ui/alert.tsx | 59 +++ orchestrator/src/components/ui/select.tsx | 159 +++++++ .../src/server/api/routes/settings.ts | 412 ++++++++---------- .../src/server/pipeline/orchestrator.ts | 9 +- .../src/server/repositories/settings.ts | 1 + orchestrator/src/server/services/pdf.ts | 184 ++++---- .../src/server/services/resumeProjects.ts | 29 +- orchestrator/src/server/services/rxresume.ts | 85 ++++ orchestrator/src/shared/types.ts | 4 +- 14 files changed, 841 insertions(+), 343 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx create mode 100644 orchestrator/src/components/ui/alert.tsx create mode 100644 orchestrator/src/components/ui/select.tsx create mode 100644 orchestrator/src/server/services/rxresume.ts 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; } From e5293fc82afb09bd47fc6d3983b52b14199cfa37 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 12:12:25 +0000 Subject: [PATCH 02/18] listing resume in settings using service correctly --- .../src/server/api/routes/settings.ts | 26 ++----------------- orchestrator/src/server/services/rxresume.ts | 10 +++++-- 2 files changed, 10 insertions(+), 26 deletions(-) diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index eb7dda8..1d0bb4f 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -7,6 +7,7 @@ import { normalizeResumeProjectsSettings, resolveResumeProjectsSettings, } from '../../services/resumeProjects.js'; +import { listResumes } from '../../services/rxresume.js'; export const settingsRouter = Router(); @@ -299,30 +300,7 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { */ 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(); + const resumes = await listResumes(); res.json({ success: true, resumes }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; diff --git a/orchestrator/src/server/services/rxresume.ts b/orchestrator/src/server/services/rxresume.ts index ba0560f..6286a87 100644 --- a/orchestrator/src/server/services/rxresume.ts +++ b/orchestrator/src/server/services/rxresume.ts @@ -22,8 +22,8 @@ export async function fetchRxResume(path: string, options: RequestInit = {}): Pr 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}`; + // The API endpoints are at /api/openapi/* + const url = `${cleanBaseUrl}/api/openapi${path}`; const headers = { 'x-api-key': apiKey, @@ -83,3 +83,9 @@ export async function exportResumePdf(id: string): Promise { const result = await fetchRxResume(`/printer/resume/${id}/pdf`); return result.url; } +/** + * List all resumes. + */ +export async function listResumes(): Promise<{ id: string; name: string }[]> { + return fetchRxResume('/resume'); +} From 55d97c8099a828a417cdec2630f6358ae21622bb Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 13:30:56 +0000 Subject: [PATCH 03/18] url location corrected for list --- .env.example | 4 ++-- orchestrator/src/server/api/routes/settings.ts | 2 +- orchestrator/src/server/services/rxresume.ts | 17 +++++++++++++---- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/.env.example b/.env.example index 9c5d7b0..3c13cfb 100644 --- a/.env.example +++ b/.env.example @@ -10,8 +10,8 @@ MODEL=openai/gpt-4o-mini # RXResume credentials for PDF generation # Create an account at: https://rxresu.me -RXRESUME_EMAIL=your_email@example.com -RXRESUME_PASSWORD=your_password_here +# for reference: https://docs.rxresu.me/guides/using-the-api +RXRESUME_API_KEY= # Optional: Basic Auth for write access (read-only without auth) # When set, all write actions (POST/PATCH/DELETE) require Basic Auth. diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index 1d0bb4f..0301e55 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -301,7 +301,7 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { settingsRouter.get('/rx-resumes', async (_req: Request, res: Response) => { try { const resumes = await listResumes(); - res.json({ success: true, resumes }); + res.json({ success: true, data: { resumes } }); } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`❌ Failed to fetch Reactive Resumes: ${message}`); diff --git a/orchestrator/src/server/services/rxresume.ts b/orchestrator/src/server/services/rxresume.ts index 6286a87..fd26939 100644 --- a/orchestrator/src/server/services/rxresume.ts +++ b/orchestrator/src/server/services/rxresume.ts @@ -20,14 +20,21 @@ export async function fetchRxResume(path: string, options: RequestInit = {}): Pr } const baseUrl = process.env.RXRESUME_URL || 'https://rxresu.me'; - const cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + let cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; + + // Handle cases where the base URL already includes /api or /api/openapi + if (cleanBaseUrl.endsWith('/api/openapi')) { + cleanBaseUrl = cleanBaseUrl.slice(0, -12); + } else if (cleanBaseUrl.endsWith('/api')) { + cleanBaseUrl = cleanBaseUrl.slice(0, -4); + } - // The API endpoints are at /api/openapi/* const url = `${cleanBaseUrl}/api/openapi${path}`; const headers = { 'x-api-key': apiKey, - 'Content-Type': 'application/json', + // intentionally removed because it doesn't work with this added... + // 'Content-Type': 'application/json', ...(options.headers || {}), } as Record; @@ -83,9 +90,11 @@ export async function exportResumePdf(id: string): Promise { const result = await fetchRxResume(`/printer/resume/${id}/pdf`); return result.url; } + /** * List all resumes. + * According to official OpenAPI spec, the endpoint is /resume/list */ export async function listResumes(): Promise<{ id: string; name: string }[]> { - return fetchRxResume('/resume'); + return fetchRxResume('/resume/list'); } From e05c8c66569db527117640699ce5c3807197bc6c Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 13:31:06 +0000 Subject: [PATCH 04/18] remove the now unused pdf generator --- resume-generator/.gitignore | 8 - resume-generator/base.bak.json | 661 ------------------ resume-generator/base.json | 362 ---------- resume-generator/generate_summary.py | 137 ---- resume-generator/rxresume_automation.py | 121 ---- ..._b551b26e-7cf0-4bc5-be69-36d8e813f5b2.json | 661 ------------------ 6 files changed, 1950 deletions(-) delete mode 100644 resume-generator/.gitignore delete mode 100644 resume-generator/base.bak.json delete mode 100644 resume-generator/base.json delete mode 100644 resume-generator/generate_summary.py delete mode 100644 resume-generator/rxresume_automation.py delete mode 100644 resume-generator/temp_resume_b551b26e-7cf0-4bc5-be69-36d8e813f5b2.json diff --git a/resume-generator/.gitignore b/resume-generator/.gitignore deleted file mode 100644 index 6036520..0000000 --- a/resume-generator/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Temp JSON files (used by orchestrator) -temp_*.json - -# Python virtual environment -.venv/ - -# Generated resumes -resumes/ diff --git a/resume-generator/base.bak.json b/resume-generator/base.bak.json deleted file mode 100644 index b0f0195..0000000 --- a/resume-generator/base.bak.json +++ /dev/null @@ -1,661 +0,0 @@ -{ - "basics": { - "name": "Shaheer Sarfaraz", - "headline": "Frontend Software Engineer (React/TypeScript) \u00b7 Autodesk Intern \u00b7 Open Source & Product Work", - "email": "shaheer30sarfaraz@gmail.com", - "phone": "+44 7359 501592", - "location": "Blackpool, United Kingdom", - "url": { - "label": "https://dakheera47.com/", - "href": "https://dakheera47.com/" - }, - "customFields": [], - "picture": { - "url": "", - "size": 120, - "aspectRatio": 1, - "borderRadius": 0, - "effects": { - "hidden": false, - "border": false, - "grayscale": false - } - } - }, - "sections": { - "summary": { - "name": "Summary", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "summary", - "content": "

I\u2019m a BSc (Hons) Computer Science student at the University of Lancashire, graduating in June 2026 with a First-class average, and I\u2019ve spent a year as a Software Engineering Intern at Autodesk working in a large React/TypeScript production codebase. I\u2019m comfortable using Python for scripting, data cleaning, and small backend services, and I have academic experience with SQL from my databases module, which I\u2019ve applied in analytics-focused side projects. I\u2019m particularly interested in AI-driven systems and would be excited to help develop and improve AI agents for marketing and user acquisition while working closely with data scientists, engineers, and marketing/product teams

" - }, - "awards": { - "name": "Awards", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "awards", - "items": [] - }, - "certifications": { - "name": "Certifications", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "certifications", - "items": [] - }, - "education": { - "name": "Education", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "education", - "items": [ - { - "id": "yo3p200zo45c6cdqc6a2vtt3", - "visible": true, - "institution": "University of Lancashire", - "studyType": "BSc (Hons) Computer Science", - "area": "Preston, United Kingdom", - "score": "1st Class", - "date": "September 2022 to June 2026", - "summary": "

Relevant Modules: Web Applications, Algorithms & Data Structures, Game Development, Databases, Software Engineering (Agile group project)

", - "url": { - "label": "", - "href": "https://www.lancashire.ac.uk/undergraduate/courses/computer-science-bsc" - } - }, - { - "id": "ei2fvjokusg3cfmdyolmgcoz", - "visible": false, - "institution": " ", - "studyType": "", - "area": "A Levels", - "score": "", - "date": "", - "summary": "
  • Maths: A

  • Computer Science: B

  • Physics: C

  • Chemistry: E

", - "url": { - "label": "", - "href": "" - } - }, - { - "id": "pm4r5hngvv1w4mc79o22irfx", - "visible": false, - "institution": " ", - "studyType": "", - "area": "GCSEs", - "score": "", - "date": "", - "summary": "
  1. English: A*

  2. Computer Science: A*

  3. Urdu: A

  4. Islamiat: A

  5. Pakistan Studies: A

  6. Biology: A

  7. Chemistry: A

  8. Physics: A

  9. Maths: A

", - "url": { - "label": "", - "href": "" - } - } - ] - }, - "experience": { - "name": "Experience", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "experience", - "items": [ - { - "id": "ng9ui2azk7w4y8oyu8kazqeb", - "visible": true, - "company": "Autodesk", - "position": "Software Engineering Intern", - "location": "Hybrid (Sheffield Based)", - "date": "July 2024 - June 2025", - "summary": "
  • Implemented front-end features and fixes in the Autodesk Construction Cloud Model Coordination app, working in a ~10-year-old React/JavaScript/TypeScript codebase (7k+ commits) using Webpack module federation and Autodesk\u2019s Exoskeleton dev environment

  • Improved reliability of the Cypress end-to-end test suite by diagnosing flaky tests, adding new E2E coverage, and participating in focused \u201ctest fest\u201d events ahead of major feature releases

  • Collaborated with cross-functional teams (like the Design System, platform teams) by raising well-scoped bugs, augmenting existing tickets with reproduction steps and context, and aligning on shared component and API changes

  • Helped strengthen team processes by running weekly stand-ups and retrospectives, organising a ticket-scoping meeting, and participating in technical reviews & ADR discussions (e.g. standardising error handling and planning clash data streaming)

", - "url": { - "label": "", - "href": "" - } - }, - { - "id": "lhw25d7gf32wgdfpsktf6e0x", - "visible": true, - "company": "Mirage", - "position": "Co-Founder & Lead Developer", - "location": "", - "date": "December 2019 to Present", - "summary": "
  • Delivered 10+ production websites and webapps for small and medium size clients (e.g. Indus Marine Services, Mumtaz Urdu), from initial scoping to deployment and handover

  • Built with modern web stacks (Next.js, Node/Express, Tailwind, Strapi, WordPress/Elementor where appropriate), setting up CI/CD and hosting

  • Led a small team of four developers, handling code reviews, task breakdown, and client communication

", - "url": { - "label": "", - "href": "https://promirage.com/" - } - }, - { - "id": "k6zxqunkb225hbjso3c3vykk", - "visible": true, - "company": "University of Lancashire", - "position": "Computing Student Mentor", - "location": "Preston, UK", - "date": "July 2023 - July 2024", - "summary": "
  • Academic Support and Leadership: Provided academic guidance to over 10 first-year students once a week, significantly enhancing their understanding and skills in key subjects like programming and web development.

  • Collaborative Learning Environment: Actively fostered a collaborative and supportive learning environment for a group of 10 students. This role also honed my leadership and communication skills, facilitating better academic outcomes for mentees.

", - "url": { - "label": "", - "href": "" - } - }, - { - "id": "a1bg5d8gp8sulf91xzdcsiaq", - "visible": true, - "company": "Research and Knowledge Exchange Institute", - "position": "Undergraduate Research Intern (HCI & EdTech)", - "location": "", - "date": "Summer 2024", - "summary": "
  • Built a mouse \u201ctorch-reveal\u201d web app (Astro) to approximate eye-tracking; ran on-campus studies with Revoe Learning Academy pupils\u20141 eye-tracked, 9 using my app.

  • Logged cursor paths, dwell time, and reveal order; delivered setup notes for staff to run sessions independently.

  • Developed a Questionnaire Randomiser (Next.js): selectable response metrics (smileys / numbers / stars), configurable randomisation strategies, and ZIP export of per-student PDFs ready for print.

  • Extras: lightweight analytics for comparison with the eye-tracking baseline; optional CSV/JSON data export.

", - "url": { - "label": "", - "href": "" - } - }, - { - "id": "tx32suzrg2bs5eumcbjei4ns", - "visible": false, - "company": "University of Lancashire", - "position": "Student Ambassador", - "location": "Preston, UK", - "date": "July 2023 - Present", - "summary": "
  • Diverse Role Engagement: Actively engaged in various tasks, from guiding tours to assisting on open days, demonstrating adaptability and organizational skills.

  • Campus Culture Promotion: Contributed to enhancing the university\u2019s inclusive campus atmosphere, showcasing the university's vibrant community to prospective students.

", - "url": { - "label": "", - "href": "" - } - } - ] - }, - "volunteer": { - "name": "Volunteering", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "volunteer", - "items": [] - }, - "interests": { - "name": "Interests", - "columns": 1, - "separateLinks": true, - "visible": false, - "id": "interests", - "items": [] - }, - "languages": { - "name": "Languages", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "languages", - "items": [] - }, - "profiles": { - "name": "Profiles", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "profiles", - "items": [ - { - "id": "ukl0uecvzkgm27mlye0wazlb", - "visible": true, - "network": "GitHub", - "username": "DaKheera47", - "icon": "github", - "url": { - "label": "", - "href": "https://github.com/DaKheera47" - } - }, - { - "id": "cnbk5f0aeqvhx69ebk7hktwd", - "visible": true, - "network": "LinkedIn", - "username": "ssarfaraz30", - "icon": "linkedin", - "url": { - "label": "", - "href": "https://www.linkedin.com/in/ssarfaraz30/" - } - }, - { - "id": "linnyxv78zdep1xwirpa2ia1", - "visible": true, - "network": "Hashnode", - "username": "DaKheera47", - "icon": "hashnode", - "url": { - "label": "", - "href": "https://dakheera47.hashnode.dev/" - } - } - ] - }, - "projects": { - "name": "Projects", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "projects", - "items": [ - { - "id": "yw843emozcth8s1ubi1ubvlf", - "visible": false, - "name": "Atoro", - "description": "Lead Developer", - "date": "January 2023", - "summary": "
  1. Next.js Implementation for Enhanced SEO: Utilized Next.js to optimize the website for search engines, significantly improving its online visibility and user engagement.

  2. Strapi Backend Integration: Streamlined content management by implementing a Strapi backend, enhancing the efficiency and scalability of the website's content updates.

  3. Responsive Design with Tailwind CSS: Employed Tailwind CSS for a utility-first approach, ensuring the website's responsiveness and seamless user experience across various devices.

  4. Continuous Deployment Pipeline Establishment: Developed a continuous deployment pipeline, ensuring real-time updates and maintaining high performance and reliability of the website.

  5. Optimized Web Performance: Focused on optimizing web performance by efficiently loading images and managing JavaScript bundles, leading to a faster and more efficient user experience.

", - "keywords": [], - "url": { - "label": "", - "href": "https://atoro.promirage.com" - } - }, - { - "id": "ncxgdjjky54gh59iz2t1xi1v", - "visible": false, - "name": "Stellar Consultancy", - "description": "Lead Developer", - "date": "April 2023", - "summary": "
  1. WordPress and Elementor Integration: Expertly utilized WordPress with Elementor to build a robust content management system, enhancing the website's scalability and user interaction capabilities.

  2. Client Engagement and Trust Building: Implemented features to showcase client testimonials, effectively building trust and displaying the success of previous project engagements.

  3. Intuitive Design and User Engagement: Focused on intuitive page design and structuring, streamlining site maintenance and content updates, thereby enhancing user engagement.

  4. Effective Call-to-Actions: Crafted clear call-to-actions and provided essential contact information, significantly improving user interaction and conversion rates.

  5. Portfolio Display for Business Showcase: Presented past work and services offered through a comprehensive portfolio display, allowing visitors to assess the quality and impact of Stellar Consultancy's services.

", - "keywords": [], - "url": { - "label": "", - "href": "https://stellarconsultancy.ca" - } - }, - { - "id": "tcecguinuctb8mu2xqrn97m8", - "visible": true, - "name": "Mumtaz Urdu", - "description": "Developer", - "date": "July 2022", - "summary": "
  1. Server-Rendered Web Application Development: Created the Mumtaz Urdu platform with Next.js to optimize server-side rendering for enhanced SEO and performance.

  2. UI Development with Tailwind CSS: Implemented utility-first Tailwind CSS, ensuring rapid, responsive design for a seamless user interface.

  3. Scalable Storage Solution: Integrated scalable Amazon S3 storage, supporting the application's growth and robust data management.

  4. Progressive Web App Implementation: Developed PWA features for Mumtaz Urdu, offering users native-like mobile access and increased engagement.

  5. High Traffic Data Management: Engineered Mumtaz Urdu's backend with Next.js and MongoDB, enabling the handling and efficient processing of vast amounts of user data for thousands of monthly users.

  6. Test-Driven Development: Embraced TDD practices to ensure reliable and high-quality code, facilitating regular testing throughout the development process for continuous improvement.

", - "keywords": [], - "url": { - "label": "", - "href": "https://www.mumtazurdu.com/" - } - }, - { - "id": "to47h749kaj6t02j3f9kprxq", - "visible": false, - "name": "PyScreeze", - "description": "Open Source Contribution", - "date": "January 2022", - "summary": "
  1. Innovative Feature Implementation: Implemented the locateCenterOnScreenNear function for PyScreeze, enhancing the library's functionality by enabling precise image location near a specified point on the screen.

  2. Open Source Contribution: Marked my debut in open-source contributions with this significant addition to PyScreeze, showcasing my initiative and ability to contribute effectively to community-driven projects.

  3. Collaborative Development and Recognition: Collaborated with the project's maintainer, asweigart, to refine and integrate the function into the main codebase, receiving recognition for this valuable contribution to the project.

", - "keywords": [], - "url": { - "label": "", - "href": "https://github.com/asweigart/pyscreeze/pull/79" - } - }, - { - "id": "gt7yq82ulor5hmmutdhuvfo1", - "visible": false, - "name": "Threegency", - "description": "Lead Developer", - "date": "February 2023", - "summary": "
  • Framework: Utilized Next.js to build a server-rendered React website, enhancing SEO and ensuring optimal performance.

  • Styling: Employed Tailwind CSS for utility-first styling, facilitating rapid UI development.

  • Content Management: Leveraged Strapi as a CMS, enabling streamlined content updates and administration.

  • Data Handling: Utilized GraphQL for data handling, ensuring efficient and flexible data retrieval.

", - "keywords": [], - "url": { - "label": "", - "href": "https://www.threegency.com" - } - }, - { - "id": "c8fcu3nz541a4d5zcurx6b8c", - "visible": false, - "name": "AutoClass", - "description": "GUI Automation", - "date": "November 2021", - "summary": "
  • Framework: Written in Python, leveraging the versatility and ease-of-use of the language.

  • Automation Library: Utilized PyAutoGUI for automating user interactions, enhancing the utility of the application.

  • Iterative Improvement: Progressively refined over a year, demonstrating a commitment to robustness and reliability.

  • Project Purpose: Developed to automate the process of joining Zoom classes, alleviating the repetitive morning routine.

", - "keywords": [], - "url": { - "label": "", - "href": "https://github.com/DaKheera47/autoclass" - } - }, - { - "id": "rv23bgibq6bye6rujmcx1ygc", - "visible": false, - "name": "Meet Link Generator", - "description": "GUI Automation", - "date": "January 2022", - "summary": "
  • Functionality: Generates Google Meet links with specific words in the URL by brute-forcing the creation of thousands of links until the desired pattern is achieved. Doing so enables creation of Google Meet links with specific codes or phrases.

  • Optimized Automation: The final product uses Python with PyAutoGUI for efficient and rapid creation of new Google Meet links.

  • Speed and Efficiency: Drastically improved performance, finally achieving the link generation time to under 1 second per link, limited only by internet speed.

  • Interface Interaction: Utilizes the Google Meet homepage's features for quicker link generation, avoiding full page refreshes for speed.

", - "keywords": [], - "url": { - "label": "", - "href": "https://github.com/DaKheera47/meet-link-generator" - } - }, - { - "id": "tu98rghbi5c43ogget5mh7ih", - "visible": false, - "name": "UCLan Server-side Web Application Project", - "description": "", - "date": "UCLan Year 1", - "summary": "
  • Backend Development with PHP and MySQL: Developed the backend for a Student\u2019s Union Shop web application, integrating PHP and MySQL for dynamic data handling and backend database communication.

  • User Authentication and Session Management: Implemented user sign-up and login functionality using PHP sessions, enabling secure and personalized shopping experiences.

  • Dynamic Content Display from Database: Enhanced the application to dynamically display products and offers directly from the database, moving away from static HTML content.

  • Advanced Search and Personalization Features: Integrated advanced product search capabilities and personalized user greetings, improving user interactivity and engagement.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "ov4lkbc1vl169ynfnj91m1lm", - "visible": false, - "name": "Square About", - "description": "", - "date": "UCLan Year 1", - "summary": "
  • Advanced 3D Game Development: Implemented a complex 3D game using TL-Engine, featuring intricate gameplay mechanics and immersive 3D visuals.

  • Dynamic Gameplay Elements: Integrated multiple spheres with varying behaviors, including super-spheres requiring multiple hits, enhancing the game's challenge and engagement levels.

  • Interactive Game Controls: Developed features for speed control and directional change, allowing players to interact dynamically with the game environment.

  • Strategic Game Mechanics: Added a bullet firing mechanism with a limited ammo concept, introducing strategic elements and a scoring system to the gameplay.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "s3r37gdr0oa84a6dp6r5nl58", - "visible": false, - "name": "Car Smash", - "description": "", - "date": "UCLan Year 1", - "summary": "
  1. 3D Car Smash Game Development: Developed a 3D car smash game using TL-Engine, showcasing skills in game engine utilization and 3D gaming.

  2. Collision Detection Mechanics: Implemented advanced collision detection between player's car and enemy vehicles, enhancing gameplay realism.

  3. Dynamic Game States and Camera Views: Integrated multiple game states and camera views, including a chase camera and first-person view, for an immersive gaming experience.

  4. Enhanced Player Interaction: Created a more realistic driving experience with accelerated movement and bounce effects on collisions, and introduced particle systems for visual effects.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "gylzkvl103m9s7ywag4xpdy4", - "visible": false, - "name": "Tweet Filter", - "description": "", - "date": "UCLan Year 1", - "summary": "
  1. Tweet Filtration System: Crafted a C++ program to filter out prohibited words from tweets, showcasing text processing and file handling capabilities.

  2. Advanced Text Manipulation: Enhanced the program to filter varying cases and contexts of banned words, even within larger strings, demonstrating attention to detail in string operations.

  3. Output Generation: Implemented functionality to write filtered tweets to new files, maintaining data integrity and displaying proficiency in file I/O operations.

  4. Algorithm Optimization: Utilized data structures like vectors and implemented mathematical techniques for efficient word frequency analysis and sentiment determination.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "enav754zxhuc9uycbb83s94q", - "visible": false, - "name": "Burger Ordering App", - "description": "", - "date": "UCLan Year 1", - "summary": "
  1. Interactive Console Application: Engineered a C++ console application simulating a burger ordering process, highlighting proficiency in creating user-interactive software.

  2. Complex Logic Implementation: Designed and implemented complex logic for burger size and topping selection, including pricing and order summary features.

  3. Data Handling and User Input: Developed robust credit system and user input validation for an intuitive ordering experience, showcasing attention to detail and user-centric design.

  4. Readable and Maintainable Code: Produced well-documented, maintainable code with clear variable naming and structured formatting, demonstrating best practices in software development.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "hl6jgeswr01tlul3iwoat05d", - "visible": false, - "name": "LinkLander", - "description": "Android Studio, Kotlin", - "date": "December 2023 - Ongoing", - "summary": "
  • Innovative Android Utility: Developed LinkLander, a Kotlin-based Android application that simplifies the process of downloading online content directly to devices.

  • User-Centric Design: Focused on addressing Android system limitations by providing a seamless shortcut for redirecting links to an online video downloading service.

  • Simplicity and Efficiency: Emphasized a user-friendly interface, enhancing the Android experience by streamlining content downloads.

  • Technical Proficiency in Kotlin: Leveraged the capabilities of Kotlin for Android development to create a practical solution for niche digital tasks.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "v4s0ljbiiio198y8l1wl0ym6", - "visible": false, - "name": "AR App Development with AGILE", - "description": "Unity, C#", - "date": "October 2023 - Ongoing", - "summary": "
  • Agile Development in Action: Participated in an Agile team project, developing an AR application for supporting disabled students with a team of five, demonstrating an application of Agile methodologies in a real-world scenario.

  • Mobile AR Application Prototype: Developed a proof-of-concept prototype using Unity and C# for mobile platforms, showcasing technical skills in modern app development environments.

  • Collaborative Software Engineering: Engaged in a collaborative environment, contributing code and ideas, emphasizing teamwork and shared responsibility in software creation.

  • Presentation and Critical Analysis: Delivered a comprehensive presentation and critical report, evaluating the Agile process, product development, and personal learning outcomes.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "fwxrq682hqrj1y76rmziqrbk", - "visible": true, - "name": "Indus Marine Services", - "description": "System Design & Development", - "date": "May 2022 - Ongoing", - "summary": "
  1. Induction System for Marine Services: Designed & developed an induction system for Indus Marine Services in the UAE, streamlining the employee onboarding process with interactive testing and certification issuance.

  2. Admin-Centric Functionality: Devised a back-end system allowing admins to oversee inductee progress, manage documents, and curate customized quizzes as per requirements

  3. Client Engagement Interface: Implemented a user-friendly front-end where inductees receive personalized email prompts, complete quizzes, and obtain certifications, all contributing to a seamless induction experience.

  4. Robust Tech Stack Integration: Utilized a sophisticated stack comprising Node.js, Express, EJS, and Tailwind CSS to build a responsive, scalable, and easily navigable system.

", - "keywords": [], - "url": { - "label": "", - "href": "http://www.ims-auh.com" - } - }, - { - "id": "jdfyaez8vq1b7xfr9rmxmz06", - "visible": false, - "name": "VECTOR AI", - "description": "Website Development", - "date": "February 2024 - February 2024", - "summary": "
  1. Innovative AI Development: As the driving force behind VECTOR's website development, I spearheaded the technical design using Astro, with a cutting-edge stack including React and Tailwind CSS.

  2. Data-Driven Content Strategy: Leveraged Astro content management capabilities to structure and present data, ensuring content is dynamic, easily accessible, and optimized for both performance and scalability.

  3. Astro for Enhanced Performance: Utilized Astro for static site generation, making VECTOR's website performance fast for a pleasant user experience

  4. React for Responsive Interaction: Utilized React\u2019s robust ecosystem to develop interactive elements, ensuring that each module of VECTOR\u2019s platform is engaging and seamless for users across various touchpoints.

", - "keywords": [], - "url": { - "label": "", - "href": "https://vector-ai.co/" - } - }, - { - "id": "qdhmfkqpfql19ohfas1g91ek", - "visible": false, - "name": "UCLan's First Hackathon", - "description": "Hackathon, Team Work", - "date": "February 2024", - "summary": "
  1. Second Place in UCLan Hackathon: Earned second place in UCLan's first hackathon by developing an app to simplify university life. Focused on enhancing the attendance monitoring process for student mentors.

  2. TRPC for End-to-End Type Safety: Utilized TRPC to ensure end-to-end type safety, enhancing the app's reliability and streamlining the development process.

  3. Supabase Backend Integration: Implemented Supabase as a backend solution, providing a robust and scalable database for managing attendance data efficiently.

  4. Amazon SES and OAuth Integration: Integrated Amazon SES for email notifications and OAuth for secure Google login, improving user experience and communication.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "rw3x7tapntrt877rbl4pnxz7", - "visible": true, - "name": "NASA Space Apps Challenge", - "description": "A 48-hour, global hackathon powered by NASA open data", - "date": "Oct 4\u20135, 2025", - "summary": "
  1. Full-Stack Integration: Wired up backend services to a responsive frontend, enabling real-time exploration of Kepler/K2/TESS catalogs and smooth model-scoring UX.

  2. Data Harmonization Pipeline: Cleaned, merged, and standardized multi-mission catalogs into a unified schema, unblocking ML teammates and cutting data-prep time by 60%+ during the hack.

  3. Analytics UI & Upload Flow: Built an upload \u2192 validate \u2192 score workflow and a clear results dashboard so researchers can triage candidates in minutes, not hours.

  4. Delivery Under Pressure: Coordinated a 5-person multidisciplinary team to ship a working web app in 48 hours, with demo-ready reliability for judging.

", - "keywords": [], - "url": { - "label": "", - "href": "https://exploranium.vercel.app/dashboard" - } - }, - { - "id": "i2t6epmx5v7s0d8rqtxsigp3", - "visible": true, - "name": "Strong Statistics", - "description": "Self-hosted strength analytics app using FastAPI and Next.js to visualize Strong app data with full local privacy and active open-source adoption.", - "date": "September 2025 - Present", - "summary": "
  1. Self-Hosted Strength Analytics Platform: Developed strong-statistics, an open-source web app that visualizes detailed workout analytics from the Strong and Hevy fitness app, giving users local control of their training data.

  2. Full-Stack Architecture: Built a modular stack with FastAPI, Next.js, Tailwind CSS, and SQLite, deployed via Docker Compose for seamless self-hosting and persistent local data storage.

  3. Active Open-Source Ecosystem: Published on GitHub with community engagement from global users \u2014 external contributors opened feature requests and bug reports, validating real-world adoption and reliability.

  4. Continuous Personal Use & Maintenance: Regularly updated and used in live deployment at lifting.dakheera47.com, tracking hundreds of sets over time with persistent analytics and performance trends.

", - "keywords": [], - "url": { - "label": "", - "href": "https://lifting.dakheera47.com/" - } - } - ] - }, - "publications": { - "name": "Publications", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "publications", - "items": [] - }, - "references": { - "name": "References", - "columns": 1, - "separateLinks": true, - "visible": false, - "id": "references", - "items": [ - { - "id": "f2sv5z0cce6ztjl87yuk8fak", - "visible": true, - "name": "Available upon request", - "description": "", - "summary": "", - "url": { - "label": "", - "href": "" - } - } - ] - }, - "skills": { - "name": "Skills", - "columns": 2, - "separateLinks": true, - "visible": true, - "id": "skills", - "items": [ - { - "id": "jfgzfcwcg65k9gemuxlfe9m3", - "visible": true, - "name": "Frontend Development", - "description": "", - "level": 0, - "keywords": [ - "React", - "Next.js", - "Tailwind CSS", - "Strapi CMS", - "Elementor", - "GraphQL", - "TypeScript", - "CI/CD", - "PWA Development", - "AstroJS", - "React Testing Library" - ] - }, - { - "id": "sk3957foopxir2hw4xzxqahh", - "visible": true, - "name": "Backend Development", - "description": "", - "level": 0, - "keywords": [ - "Node.js", - "Express.js", - "MongoDB", - "Supabase", - "Firebase", - "Docker", - "FastAPI", - "AWS S3", - "AWS SES" - ] - }, - { - "id": "d9bddwdj6qreknhk644rm0bs", - "visible": true, - "name": "Leadership and Problem-Solving", - "description": "", - "level": 0, - "keywords": [ - "Agile Project Management", - "Conflict Resolution", - "Creative Problem-Solving", - "Decision-Making", - "Effective Communication", - "Adaptability" - ] - }, - { - "id": "gk4hrky0wnbsbdcmmud48zjh", - "visible": true, - "name": "Other Programming", - "description": "", - "level": 0, - "keywords": [ - "Python Scripting", - "PyAutoGUI", - "Git", - "GitHub", - "Selenium", - "Data Analysis", - "Web Scraping", - "Data Cleaning" - ] - } - ] - }, - "custom": {} - }, - "metadata": { - "template": "onyx", - "layout": [ - [ - [ - "summary", - "education", - "experience", - "projects", - "references" - ], - [ - "profiles", - "skills", - "certifications", - "interests", - "languages", - "awards", - "volunteer", - "publications" - ] - ] - ], - "css": { - "value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}", - "visible": false - }, - "page": { - "margin": 34, - "format": "a4", - "options": { - "breakLine": false, - "pageNumbers": false - } - }, - "theme": { - "background": "#ffffff", - "text": "#000000", - "primary": "#475569" - }, - "typography": { - "font": { - "family": "IBM Plex Sans", - "subset": "latin", - "variants": [ - "regular" - ], - "size": 13 - }, - "lineHeight": 1.75, - "hideIcons": false, - "underlineLinks": true - }, - "notes": "" - } -} \ No newline at end of file diff --git a/resume-generator/base.json b/resume-generator/base.json deleted file mode 100644 index dc074ac..0000000 --- a/resume-generator/base.json +++ /dev/null @@ -1,362 +0,0 @@ -{ - "basics": { - "url": { - "href": "https://dakheera47.com/", - "label": "https://dakheera47.com/" - }, - "name": "Shaheer Sarfaraz", - "email": "shaheer30sarfaraz@gmail.com", - "phone": "+44 7359 501592", - "picture": { - "url": "", - "size": 120, - "effects": { - "border": false, - "hidden": false, - "grayscale": false - }, - "aspectRatio": 1, - "borderRadius": 0 - }, - "headline": "Frontend Software Engineer (React/TypeScript) · Autodesk Intern", - "location": "Blackpool, United Kingdom", - "customFields": [] - }, - "metadata": { - "css": { - "value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}", - "visible": false - }, - "page": { - "format": "a4", - "margin": 34, - "options": { - "breakLine": false, - "pageNumbers": false - } - }, - "notes": "", - "theme": { - "text": "#000000", - "primary": "#475569", - "background": "#ffffff" - }, - "layout": [ - [ - [ - "summary", - "profiles", - "experience", - "projects", - "education" - ], - [ - "skills", - "languages" - ] - ] - ], - "template": "onyx", - "typography": { - "font": { - "size": 13, - "family": "IBM Plex Sans", - "subset": "latin", - "variants": [ - "regular" - ] - }, - "hideIcons": false, - "lineHeight": 1.75, - "underlineLinks": true - } - }, - "sections": { - "awards": { - "id": "awards", - "name": "Awards", - "items": [], - "columns": 1, - "visible": true, - "separateLinks": true - }, - "custom": {}, - "skills": { - "id": "skills", - "name": "Skills", - "items": [ - { - "id": "jfgzfcwcg65k9gemuxlfe9m3", - "name": "Frontend", - "level": 0, - "visible": true, - "keywords": [ - "React", - "Next.js", - "TypeScript", - "Tailwind CSS", - "Redux", - "Astro", - "GraphQL", - "Webpack" - ], - "description": "" - }, - { - "id": "sk3957foopxir2hw4xzxqahh", - "name": "Backend & Tools", - "level": 0, - "visible": true, - "keywords": [ - "Node.js", - "Express", - "Python (FastAPI)", - "PostgreSQL", - "MongoDB", - "Docker", - "AWS (S3)", - "Git/GitHub", - "Cypress", - "Jest" - ], - "description": "" - } - ], - "columns": 2, - "visible": true, - "separateLinks": true - }, - "summary": { - "id": "summary", - "name": "Summary", - "columns": 1, - "content": "

Frontend Software Engineer with 1 year of production experience at Autodesk and a First-Class CS Degree. Specialist in modernizing legacy React/TypeScript codebases, optimizing CI/CD pipelines, and building scalable UI infrastructure.

", - "visible": true, - "separateLinks": true - }, - "profiles": { - "id": "profiles", - "name": "Profiles", - "items": [ - { - "id": "ukl0uecvzkgm27mlye0wazlb", - "url": { - "href": "https://github.com/DaKheera47", - "label": "" - }, - "icon": "github", - "network": "GitHub", - "visible": true, - "username": "DaKheera47" - }, - { - "id": "cnbk5f0aeqvhx69ebk7hktwd", - "url": { - "href": "https://www.linkedin.com/in/ssarfaraz30/", - "label": "" - }, - "icon": "linkedin", - "network": "LinkedIn", - "visible": true, - "username": "ssarfaraz30" - } - ], - "columns": 2, - "visible": true, - "separateLinks": true - }, - "projects": { - "id": "projects", - "name": "Projects", - "items": [ - { - "id": "i2t6epmx5v7s0d8rqtxsigp3", - "url": { - "href": "https://lifting.dakheera47.com/", - "label": "" - }, - "date": "September 2025 - Present", - "name": "Strong Statistics (Open Source)", - "summary": "
  • Engineered a self-hosted analytics platform using FastAPI and Docker, enabling users to regain full data sovereignty from proprietary fitness apps.

  • Maintained active open-source repo, triaging issues and merging PRs from global contributors to improve data visualization features.

", - "visible": true, - "keywords": [], - "description": "FastAPI, Next.js, Docker, SQLite" - }, - { - "id": "rw3x7tapntrt877rbl4pnxz7", - "url": { - "href": "https://exploranium.vercel.app/dashboard", - "label": "" - }, - "date": "Oct 4–5, 2025", - "name": "NASA Space Apps Challenge", - "summary": "
  • Built a real-time analytics dashboard in 48 hours, integrating backend services to visualize Kepler/TESS catalogs for ML scoring.

  • Reduced data-prep time by 60% by designing a harmonization pipeline that standardized multi-mission astronomical datasets.

", - "visible": false, - "keywords": [], - "description": "Hackathon Winner" - }, - { - "id": "tcecguinuctb8mu2xqrn97m8", - "url": { - "href": "https://www.mumtazurdu.com/", - "label": "" - }, - "date": "July 2022", - "name": "Mumtaz Urdu", - "summary": "
  • Scaled a Next.js educational platform to support thousands of monthly users, utilizing MongoDB aggregation pipelines for sub-second data processing.

  • Maximized user retention by engineering a Progressive Web App (PWA) with offline caching strategies, delivering a native-app-like mobile experience.

", - "visible": true, - "keywords": [], - "description": "Next.js, MongoDB, AWS S3" - }, - { - "id": "fwxrq682hqrj1y76rmziqrbk", - "url": { - "href": "http://www.ims-auh.com", - "label": "" - }, - "date": "May 2022 - Ongoing", - "name": "Indus Marine Services", - "summary": "
  • Architected a digital induction system using Node.js and EJS, automating compliance testing and certification issuance for marine staff.

", - "visible": true, - "keywords": [], - "description": "Node.js, Express, EJS" - } - ], - "columns": 1, - "visible": true, - "separateLinks": true - }, - "education": { - "id": "education", - "name": "Education", - "items": [ - { - "id": "yo3p200zo45c6cdqc6a2vtt3", - "url": { - "href": "https://www.lancashire.ac.uk/undergraduate/courses/computer-science-bsc", - "label": "" - }, - "area": "Preston, United Kingdom", - "date": "September 2022 to June 2026", - "score": "1st Class", - "summary": "

Relevant Modules: Web Applications, Algorithms & Data Structures, Software Engineering (Agile), Databases.

", - "visible": true, - "studyType": "BSc (Hons) Computer Science", - "institution": "University of Lancashire" - } - ], - "columns": 1, - "visible": true, - "separateLinks": true - }, - "interests": { - "id": "interests", - "name": "Interests", - "items": [], - "columns": 1, - "visible": false, - "separateLinks": true - }, - "languages": { - "id": "languages", - "name": "Languages", - "items": [], - "columns": 1, - "visible": true, - "separateLinks": true - }, - "volunteer": { - "id": "volunteer", - "name": "Volunteering", - "items": [], - "columns": 1, - "visible": false, - "separateLinks": true - }, - "experience": { - "id": "experience", - "name": "Experience", - "items": [ - { - "id": "ng9ui2azk7w4y8oyu8kazqeb", - "url": { - "href": "", - "label": "" - }, - "date": "July 2024 - June 2025", - "company": "Autodesk", - "summary": "
  • Modernized a legacy 10-year-old React/TypeScript codebase (7k+ commits) by implementing Webpack Module Federation, enabling independent deployment of micro-frontends.

  • Drove technical decision-making by authoring ADRs (Architectural Decision Records) for error handling standardization and Clash Data streaming, aligning platform-wide engineering practices.

  • Secured release pipelines by resolving flaky Cypress E2E tests during 'Test Fests', directly preventing production regressions for major feature drops.

", - "visible": true, - "location": "Hybrid (Sheffield Based)", - "position": "Software Engineering Intern" - }, - { - "id": "lhw25d7gf32wgdfpsktf6e0x", - "url": { - "href": "https://promirage.com/", - "label": "" - }, - "date": "December 2019 to Present", - "company": "Mirage", - "summary": "
  • Delivered 10+ production web applications for clients using Next.js, Tailwind, and Node.js, managing the full lifecycle from technical scoping to CI/CD deployment.

  • Led a remote team of 4 developers, establishing code review standards and sprint workflows that ensured 100% on-time delivery for clients like Indus Marine.

", - "visible": true, - "location": "", - "position": "Lead Full Stack Engineer (Contract)" - }, - { - "id": "a1bg5d8gp8sulf91xzdcsiaq", - "url": { - "href": "", - "label": "" - }, - "date": "Summer 2024", - "company": "Research and Knowledge Exchange Institute", - "summary": "
  • Engineered a React/Astro web app to approximate eye-tracking data, enabling low-cost HCI research for 10+ student participants.

  • Automated data collection pipelines by building a Next.js Questionnaire Randomiser that generates per-student PDF reports, eliminating 10+ hours of manual data entry.

", - "visible": true, - "location": "", - "position": "Undergraduate Research Intern (HCI & EdTech)" - }, - { - "id": "k6zxqunkb225hbjso3c3vykk", - "url": { - "href": "", - "label": "" - }, - "date": "July 2023 - July 2024", - "company": "University of Lancashire", - "summary": "
  • Mentored 10+ first-year students in full-stack development, facilitating weekly code reviews and technical workshops that improved pass rates.

", - "visible": false, - "location": "Preston, UK", - "position": "Computing Student Mentor" - } - ], - "columns": 1, - "visible": true, - "separateLinks": true - }, - "references": { - "id": "references", - "name": "References", - "items": [], - "columns": 1, - "visible": false, - "separateLinks": true - }, - "publications": { - "id": "publications", - "name": "Publications", - "items": [], - "columns": 1, - "visible": false, - "separateLinks": true - }, - "certifications": { - "id": "certifications", - "name": "Certifications", - "items": [], - "columns": 1, - "visible": true, - "separateLinks": true - } - } -} \ No newline at end of file diff --git a/resume-generator/generate_summary.py b/resume-generator/generate_summary.py deleted file mode 100644 index 4879476..0000000 --- a/resume-generator/generate_summary.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Generate a tailored résumé summary using AI (OpenRouter API). -""" - -import os -import json -import requests -import pyperclip -from dotenv import load_dotenv - - -def load_profile(path: str = "./base.json") -> dict: - """Load the user's profile from a JSON file.""" - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -def load_job_description(from_clipboard: bool = True, path: str = None) -> str: - """ - Load the job description from clipboard or a file. - - Args: - from_clipboard: If True, read from system clipboard - path: If from_clipboard is False, read from this file path - - Returns: - The job description text - """ - if from_clipboard: - return pyperclip.paste().strip() - if path: - with open(path, "r", encoding="utf-8") as f: - return f.read().strip() - raise ValueError("No job description source provided.") - - -def _build_prompt(profile: dict, jd: str) -> str: - """Build the prompt for the AI model.""" - return f""" -You are generating a tailored résumé summary for me. - -Requirements: -- Use keywords found in the job description. -- Keep it concise but meaningful. Avoid fluff. Avoid long-winded text. -- Include just enough detail to feel real and grounded. -- Gently convey that I care about helping people and doing good work. -- Do NOT invent experience or skills I don't have. -- Maintain a warm, confident, human tone. -- Target THIS specific job directly, so use ATS keywords, while remaining natural. -- Use the profile to add context and details. - -My profile (JSON fields merged): -{json.dumps(profile, indent=2)} - -Job description: -{jd} - -Write the résumé summary now. -""" - - -def _call_openrouter(prompt: str, model: str, api_key: str) -> str: - """Call OpenRouter API to generate text.""" - url = "https://openrouter.ai/api/v1/chat/completions" - - headers = { - "Authorization": f"Bearer {api_key}", - "HTTP-Referer": "http://localhost", - "X-Title": "ResumeSummaryScript", - "Content-Type": "application/json", - } - - payload = { - "model": model, - "messages": [{"role": "user", "content": prompt}], - } - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code != 200: - raise RuntimeError(f"OpenRouter error {response.status_code}: {response.text}") - - data = response.json() - return data["choices"][0]["message"]["content"] - - -def generate_resume_summary( - profile_path: str = "./base.json", - job_description: str = None, - from_clipboard: bool = True, - copy_to_clipboard: bool = True, -) -> str: - """ - Generate a tailored résumé summary using AI. - - Uses the user's profile and a job description to generate a personalized - summary section for a résumé, targeting the specific job. - - Args: - profile_path: Path to the profile JSON file - job_description: Job description text (if None, uses from_clipboard/path) - from_clipboard: If job_description is None, read JD from clipboard - copy_to_clipboard: If True, copy the generated summary to clipboard - - Returns: - The generated résumé summary text - """ - load_dotenv() - - api_key = os.getenv("OPENROUTER_API_KEY") - model = os.getenv("MODEL", "openai/gpt-4o-mini") - - if not api_key: - raise RuntimeError("Missing OPENROUTER_API_KEY in .env") - - profile = load_profile(profile_path) - - if job_description is None: - jd = load_job_description(from_clipboard=from_clipboard) - else: - jd = job_description - - prompt = _build_prompt(profile, jd) - summary = _call_openrouter(prompt, model, api_key) - - if copy_to_clipboard: - pyperclip.copy(summary) - - return summary - - -if __name__ == "__main__": - summary = generate_resume_summary() - - print("\n=== Generated Summary ===\n") - print(summary) - print("\n[Summary copied to clipboard]\n") diff --git a/resume-generator/rxresume_automation.py b/resume-generator/rxresume_automation.py deleted file mode 100644 index 7f2262e..0000000 --- a/resume-generator/rxresume_automation.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Automate RXResume (rxresu.me) to import resume and export PDF using Playwright. -""" - -import os -from pathlib import Path -from playwright.sync_api import sync_playwright - -# Configuration -RXRESUME_EMAIL = os.getenv("RXRESUME_EMAIL", "") -RXRESUME_PASSWORD = os.getenv("RXRESUME_PASSWORD", "") - -BASE_DIR = Path(__file__).parent - -# Allow override via environment variables (used by orchestrator) -_custom_json_path = os.getenv("RESUME_JSON_PATH") -RESUME_JSON_PATH = ( - Path(_custom_json_path) if _custom_json_path else BASE_DIR / "base.json" -) - -_custom_output_filename = os.getenv("OUTPUT_FILENAME") -OUTPUT_FILENAME = _custom_output_filename if _custom_output_filename else "resume.pdf" - -# Output directory - can be overridden by orchestrator -_custom_output_dir = os.getenv("OUTPUT_DIR") -OUTPUT_DIR = Path(_custom_output_dir) if _custom_output_dir else BASE_DIR / "resumes" - - -def login(page): - """Log in to RXResume.""" - page.goto("https://rxresu.me/auth/login") - page.fill('input[placeholder="john.doe@example.com"]', RXRESUME_EMAIL) - page.fill('input[type="password"]', RXRESUME_PASSWORD) - page.click('button:has-text("Sign in")') - page.wait_for_url("**/dashboard/resumes", timeout=15000) - page.click('button:has-text("List")') - - -def import_resume(page, json_path: Path): - """Import a resume JSON file.""" - page.click('h4:has-text("Import")') - page.set_input_files('input[type="file"]', str(json_path)) - page.click('button:has-text("Validate")') - page.click('button:has-text("Import")') - - -def navigate_to_top_resume(page): - """Navigate to the first resume in the editor.""" - if "/dashboard/resumes" not in page.url: - page.goto("https://rxresu.me/dashboard/resumes") - page.wait_for_load_state("networkidle") - - # wait a beat for the list to update - page.wait_for_timeout(1000) - page.click('span[data-state="closed"]:first-of-type div:first-of-type') - page.wait_for_url("**/builder/**", timeout=10000) - - -def export_pdf(page, output_path: Path) -> Path: - """Export the resume as PDF.""" - page.wait_for_timeout(1500) # Wait for builder to fully load - - selector = "div.inline-flex.items-center.justify-center.rounded-full.bg-background.px-4.shadow-xl button:last-of-type" - - with page.expect_download(timeout=30000) as download_info: - page.click(selector) - - download = download_info.value - output_path.parent.mkdir(parents=True, exist_ok=True) - download.save_as(str(output_path)) - return output_path - - -def generate_resume_pdf( - output_filename: str = None, - import_json: bool = True, - json_path: Path = None, -) -> Path: - """ - Import resume and export PDF. - - Args: - output_filename: Name of the output PDF file (defaults to OUTPUT_FILENAME env var) - import_json: Whether to import a JSON file first (default True) - json_path: Path to JSON file (defaults to RESUME_JSON_PATH env var) - - Returns: - Path to the generated PDF - """ - # Use environment-provided defaults - actual_filename = output_filename or OUTPUT_FILENAME - actual_json_path = json_path or RESUME_JSON_PATH - output_path = OUTPUT_DIR / actual_filename - - print(f"📄 Generating PDF: {actual_filename}") - print(f" JSON source: {actual_json_path}") - - with sync_playwright() as playwright: - browser = playwright.firefox.launch(headless=True) - context = browser.new_context() - page = context.new_page() - - try: - login(page) - - if import_json: - import_resume(page, actual_json_path) - - navigate_to_top_resume(page) - export_pdf(page, output_path) - finally: - browser.close() - - print(f"✅ PDF saved: {output_path}") - return output_path - - -if __name__ == "__main__": - # When run directly, use environment variables or defaults - pdf_path = generate_resume_pdf() - print(f"Done! PDF saved: {pdf_path}") diff --git a/resume-generator/temp_resume_b551b26e-7cf0-4bc5-be69-36d8e813f5b2.json b/resume-generator/temp_resume_b551b26e-7cf0-4bc5-be69-36d8e813f5b2.json deleted file mode 100644 index 681171e..0000000 --- a/resume-generator/temp_resume_b551b26e-7cf0-4bc5-be69-36d8e813f5b2.json +++ /dev/null @@ -1,661 +0,0 @@ -{ - "basics": { - "name": "Shaheer Sarfaraz", - "headline": "Frontend Software Engineer (React/TypeScript) · Autodesk Intern · Open Source & Product Work", - "email": "shaheer30sarfaraz@gmail.com", - "phone": "+44 7359 501592", - "location": "Blackpool, United Kingdom", - "url": { - "label": "https://dakheera47.com/", - "href": "https://dakheera47.com/" - }, - "customFields": [], - "picture": { - "url": "", - "size": 120, - "aspectRatio": 1, - "borderRadius": 0, - "effects": { - "hidden": false, - "border": false, - "grayscale": false - } - } - }, - "sections": { - "summary": { - "name": "Summary", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "summary", - "content": "" - }, - "awards": { - "name": "Awards", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "awards", - "items": [] - }, - "certifications": { - "name": "Certifications", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "certifications", - "items": [] - }, - "education": { - "name": "Education", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "education", - "items": [ - { - "id": "yo3p200zo45c6cdqc6a2vtt3", - "visible": true, - "institution": "University of Lancashire", - "studyType": "BSc (Hons) Computer Science", - "area": "Preston, United Kingdom", - "score": "1st Class", - "date": "September 2022 to June 2026", - "summary": "

Relevant Modules: Web Applications, Algorithms & Data Structures, Game Development, Databases, Software Engineering (Agile group project)

", - "url": { - "label": "", - "href": "https://www.lancashire.ac.uk/undergraduate/courses/computer-science-bsc" - } - }, - { - "id": "ei2fvjokusg3cfmdyolmgcoz", - "visible": false, - "institution": " ", - "studyType": "", - "area": "A Levels", - "score": "", - "date": "", - "summary": "
  • Maths: A

  • Computer Science: B

  • Physics: C

  • Chemistry: E

", - "url": { - "label": "", - "href": "" - } - }, - { - "id": "pm4r5hngvv1w4mc79o22irfx", - "visible": false, - "institution": " ", - "studyType": "", - "area": "GCSEs", - "score": "", - "date": "", - "summary": "
  1. English: A*

  2. Computer Science: A*

  3. Urdu: A

  4. Islamiat: A

  5. Pakistan Studies: A

  6. Biology: A

  7. Chemistry: A

  8. Physics: A

  9. Maths: A

", - "url": { - "label": "", - "href": "" - } - } - ] - }, - "experience": { - "name": "Experience", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "experience", - "items": [ - { - "id": "ng9ui2azk7w4y8oyu8kazqeb", - "visible": true, - "company": "Autodesk", - "position": "Software Engineering Intern", - "location": "Hybrid (Sheffield Based)", - "date": "July 2024 - June 2025", - "summary": "
  • Implemented front-end features and fixes in the Autodesk Construction Cloud Model Coordination app, working in a ~10-year-old React/JavaScript/TypeScript codebase (7k+ commits) using Webpack module federation and Autodesk’s Exoskeleton dev environment

  • Improved reliability of the Cypress end-to-end test suite by diagnosing flaky tests, adding new E2E coverage, and participating in focused “test fest” events ahead of major feature releases

  • Collaborated with cross-functional teams (like the Design System, platform teams) by raising well-scoped bugs, augmenting existing tickets with reproduction steps and context, and aligning on shared component and API changes

  • Helped strengthen team processes by running weekly stand-ups and retrospectives, organising a ticket-scoping meeting, and participating in technical reviews & ADR discussions (e.g. standardising error handling and planning clash data streaming)

", - "url": { - "label": "", - "href": "" - } - }, - { - "id": "lhw25d7gf32wgdfpsktf6e0x", - "visible": true, - "company": "Mirage", - "position": "Co-Founder & Lead Developer", - "location": "", - "date": "December 2019 to Present", - "summary": "
  • Delivered 10+ production websites and webapps for small and medium size clients (e.g. Indus Marine Services, Mumtaz Urdu), from initial scoping to deployment and handover

  • Built with modern web stacks (Next.js, Node/Express, Tailwind, Strapi, WordPress/Elementor where appropriate), setting up CI/CD and hosting

  • Led a small team of four developers, handling code reviews, task breakdown, and client communication

", - "url": { - "label": "", - "href": "https://promirage.com/" - } - }, - { - "id": "k6zxqunkb225hbjso3c3vykk", - "visible": true, - "company": "University of Lancashire", - "position": "Computing Student Mentor", - "location": "Preston, UK", - "date": "July 2023 - July 2024", - "summary": "
  • Academic Support and Leadership: Provided academic guidance to over 10 first-year students once a week, significantly enhancing their understanding and skills in key subjects like programming and web development.

  • Collaborative Learning Environment: Actively fostered a collaborative and supportive learning environment for a group of 10 students. This role also honed my leadership and communication skills, facilitating better academic outcomes for mentees.

", - "url": { - "label": "", - "href": "" - } - }, - { - "id": "a1bg5d8gp8sulf91xzdcsiaq", - "visible": true, - "company": "Research and Knowledge Exchange Institute", - "position": "Undergraduate Research Intern (HCI & EdTech)", - "location": "", - "date": "Summer 2024", - "summary": "
  • Built a mouse “torch-reveal” web app (Astro) to approximate eye-tracking; ran on-campus studies with Revoe Learning Academy pupils—1 eye-tracked, 9 using my app.

  • Logged cursor paths, dwell time, and reveal order; delivered setup notes for staff to run sessions independently.

  • Developed a Questionnaire Randomiser (Next.js): selectable response metrics (smileys / numbers / stars), configurable randomisation strategies, and ZIP export of per-student PDFs ready for print.

  • Extras: lightweight analytics for comparison with the eye-tracking baseline; optional CSV/JSON data export.

", - "url": { - "label": "", - "href": "" - } - }, - { - "id": "tx32suzrg2bs5eumcbjei4ns", - "visible": false, - "company": "University of Lancashire", - "position": "Student Ambassador", - "location": "Preston, UK", - "date": "July 2023 - Present", - "summary": "
  • Diverse Role Engagement: Actively engaged in various tasks, from guiding tours to assisting on open days, demonstrating adaptability and organizational skills.

  • Campus Culture Promotion: Contributed to enhancing the university’s inclusive campus atmosphere, showcasing the university's vibrant community to prospective students.

", - "url": { - "label": "", - "href": "" - } - } - ] - }, - "volunteer": { - "name": "Volunteering", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "volunteer", - "items": [] - }, - "interests": { - "name": "Interests", - "columns": 1, - "separateLinks": true, - "visible": false, - "id": "interests", - "items": [] - }, - "languages": { - "name": "Languages", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "languages", - "items": [] - }, - "profiles": { - "name": "Profiles", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "profiles", - "items": [ - { - "id": "ukl0uecvzkgm27mlye0wazlb", - "visible": true, - "network": "GitHub", - "username": "DaKheera47", - "icon": "github", - "url": { - "label": "", - "href": "https://github.com/DaKheera47" - } - }, - { - "id": "cnbk5f0aeqvhx69ebk7hktwd", - "visible": true, - "network": "LinkedIn", - "username": "ssarfaraz30", - "icon": "linkedin", - "url": { - "label": "", - "href": "https://www.linkedin.com/in/ssarfaraz30/" - } - }, - { - "id": "linnyxv78zdep1xwirpa2ia1", - "visible": true, - "network": "Hashnode", - "username": "DaKheera47", - "icon": "hashnode", - "url": { - "label": "", - "href": "https://dakheera47.hashnode.dev/" - } - } - ] - }, - "projects": { - "name": "Projects", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "projects", - "items": [ - { - "id": "yw843emozcth8s1ubi1ubvlf", - "visible": false, - "name": "Atoro", - "description": "Lead Developer", - "date": "January 2023", - "summary": "
  1. Next.js Implementation for Enhanced SEO: Utilized Next.js to optimize the website for search engines, significantly improving its online visibility and user engagement.

  2. Strapi Backend Integration: Streamlined content management by implementing a Strapi backend, enhancing the efficiency and scalability of the website's content updates.

  3. Responsive Design with Tailwind CSS: Employed Tailwind CSS for a utility-first approach, ensuring the website's responsiveness and seamless user experience across various devices.

  4. Continuous Deployment Pipeline Establishment: Developed a continuous deployment pipeline, ensuring real-time updates and maintaining high performance and reliability of the website.

  5. Optimized Web Performance: Focused on optimizing web performance by efficiently loading images and managing JavaScript bundles, leading to a faster and more efficient user experience.

", - "keywords": [], - "url": { - "label": "", - "href": "https://atoro.promirage.com" - } - }, - { - "id": "ncxgdjjky54gh59iz2t1xi1v", - "visible": false, - "name": "Stellar Consultancy", - "description": "Lead Developer", - "date": "April 2023", - "summary": "
  1. WordPress and Elementor Integration: Expertly utilized WordPress with Elementor to build a robust content management system, enhancing the website's scalability and user interaction capabilities.

  2. Client Engagement and Trust Building: Implemented features to showcase client testimonials, effectively building trust and displaying the success of previous project engagements.

  3. Intuitive Design and User Engagement: Focused on intuitive page design and structuring, streamlining site maintenance and content updates, thereby enhancing user engagement.

  4. Effective Call-to-Actions: Crafted clear call-to-actions and provided essential contact information, significantly improving user interaction and conversion rates.

  5. Portfolio Display for Business Showcase: Presented past work and services offered through a comprehensive portfolio display, allowing visitors to assess the quality and impact of Stellar Consultancy's services.

", - "keywords": [], - "url": { - "label": "", - "href": "https://stellarconsultancy.ca" - } - }, - { - "id": "tcecguinuctb8mu2xqrn97m8", - "visible": true, - "name": "Mumtaz Urdu", - "description": "Developer", - "date": "July 2022", - "summary": "
  1. Server-Rendered Web Application Development: Created the Mumtaz Urdu platform with Next.js to optimize server-side rendering for enhanced SEO and performance.

  2. UI Development with Tailwind CSS: Implemented utility-first Tailwind CSS, ensuring rapid, responsive design for a seamless user interface.

  3. Scalable Storage Solution: Integrated scalable Amazon S3 storage, supporting the application's growth and robust data management.

  4. Progressive Web App Implementation: Developed PWA features for Mumtaz Urdu, offering users native-like mobile access and increased engagement.

  5. High Traffic Data Management: Engineered Mumtaz Urdu's backend with Next.js and MongoDB, enabling the handling and efficient processing of vast amounts of user data for thousands of monthly users.

  6. Test-Driven Development: Embraced TDD practices to ensure reliable and high-quality code, facilitating regular testing throughout the development process for continuous improvement.

", - "keywords": [], - "url": { - "label": "", - "href": "https://www.mumtazurdu.com/" - } - }, - { - "id": "to47h749kaj6t02j3f9kprxq", - "visible": false, - "name": "PyScreeze", - "description": "Open Source Contribution", - "date": "January 2022", - "summary": "
  1. Innovative Feature Implementation: Implemented the locateCenterOnScreenNear function for PyScreeze, enhancing the library's functionality by enabling precise image location near a specified point on the screen.

  2. Open Source Contribution: Marked my debut in open-source contributions with this significant addition to PyScreeze, showcasing my initiative and ability to contribute effectively to community-driven projects.

  3. Collaborative Development and Recognition: Collaborated with the project's maintainer, asweigart, to refine and integrate the function into the main codebase, receiving recognition for this valuable contribution to the project.

", - "keywords": [], - "url": { - "label": "", - "href": "https://github.com/asweigart/pyscreeze/pull/79" - } - }, - { - "id": "gt7yq82ulor5hmmutdhuvfo1", - "visible": false, - "name": "Threegency", - "description": "Lead Developer", - "date": "February 2023", - "summary": "
  • Framework: Utilized Next.js to build a server-rendered React website, enhancing SEO and ensuring optimal performance.

  • Styling: Employed Tailwind CSS for utility-first styling, facilitating rapid UI development.

  • Content Management: Leveraged Strapi as a CMS, enabling streamlined content updates and administration.

  • Data Handling: Utilized GraphQL for data handling, ensuring efficient and flexible data retrieval.

", - "keywords": [], - "url": { - "label": "", - "href": "https://www.threegency.com" - } - }, - { - "id": "c8fcu3nz541a4d5zcurx6b8c", - "visible": false, - "name": "AutoClass", - "description": "GUI Automation", - "date": "November 2021", - "summary": "
  • Framework: Written in Python, leveraging the versatility and ease-of-use of the language.

  • Automation Library: Utilized PyAutoGUI for automating user interactions, enhancing the utility of the application.

  • Iterative Improvement: Progressively refined over a year, demonstrating a commitment to robustness and reliability.

  • Project Purpose: Developed to automate the process of joining Zoom classes, alleviating the repetitive morning routine.

", - "keywords": [], - "url": { - "label": "", - "href": "https://github.com/DaKheera47/autoclass" - } - }, - { - "id": "rv23bgibq6bye6rujmcx1ygc", - "visible": false, - "name": "Meet Link Generator", - "description": "GUI Automation", - "date": "January 2022", - "summary": "
  • Functionality: Generates Google Meet links with specific words in the URL by brute-forcing the creation of thousands of links until the desired pattern is achieved. Doing so enables creation of Google Meet links with specific codes or phrases.

  • Optimized Automation: The final product uses Python with PyAutoGUI for efficient and rapid creation of new Google Meet links.

  • Speed and Efficiency: Drastically improved performance, finally achieving the link generation time to under 1 second per link, limited only by internet speed.

  • Interface Interaction: Utilizes the Google Meet homepage's features for quicker link generation, avoiding full page refreshes for speed.

", - "keywords": [], - "url": { - "label": "", - "href": "https://github.com/DaKheera47/meet-link-generator" - } - }, - { - "id": "tu98rghbi5c43ogget5mh7ih", - "visible": false, - "name": "UCLan Server-side Web Application Project", - "description": "", - "date": "UCLan Year 1", - "summary": "
  • Backend Development with PHP and MySQL: Developed the backend for a Student’s Union Shop web application, integrating PHP and MySQL for dynamic data handling and backend database communication.

  • User Authentication and Session Management: Implemented user sign-up and login functionality using PHP sessions, enabling secure and personalized shopping experiences.

  • Dynamic Content Display from Database: Enhanced the application to dynamically display products and offers directly from the database, moving away from static HTML content.

  • Advanced Search and Personalization Features: Integrated advanced product search capabilities and personalized user greetings, improving user interactivity and engagement.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "ov4lkbc1vl169ynfnj91m1lm", - "visible": false, - "name": "Square About", - "description": "", - "date": "UCLan Year 1", - "summary": "
  • Advanced 3D Game Development: Implemented a complex 3D game using TL-Engine, featuring intricate gameplay mechanics and immersive 3D visuals.

  • Dynamic Gameplay Elements: Integrated multiple spheres with varying behaviors, including super-spheres requiring multiple hits, enhancing the game's challenge and engagement levels.

  • Interactive Game Controls: Developed features for speed control and directional change, allowing players to interact dynamically with the game environment.

  • Strategic Game Mechanics: Added a bullet firing mechanism with a limited ammo concept, introducing strategic elements and a scoring system to the gameplay.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "s3r37gdr0oa84a6dp6r5nl58", - "visible": false, - "name": "Car Smash", - "description": "", - "date": "UCLan Year 1", - "summary": "
  1. 3D Car Smash Game Development: Developed a 3D car smash game using TL-Engine, showcasing skills in game engine utilization and 3D gaming.

  2. Collision Detection Mechanics: Implemented advanced collision detection between player's car and enemy vehicles, enhancing gameplay realism.

  3. Dynamic Game States and Camera Views: Integrated multiple game states and camera views, including a chase camera and first-person view, for an immersive gaming experience.

  4. Enhanced Player Interaction: Created a more realistic driving experience with accelerated movement and bounce effects on collisions, and introduced particle systems for visual effects.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "gylzkvl103m9s7ywag4xpdy4", - "visible": false, - "name": "Tweet Filter", - "description": "", - "date": "UCLan Year 1", - "summary": "
  1. Tweet Filtration System: Crafted a C++ program to filter out prohibited words from tweets, showcasing text processing and file handling capabilities.

  2. Advanced Text Manipulation: Enhanced the program to filter varying cases and contexts of banned words, even within larger strings, demonstrating attention to detail in string operations.

  3. Output Generation: Implemented functionality to write filtered tweets to new files, maintaining data integrity and displaying proficiency in file I/O operations.

  4. Algorithm Optimization: Utilized data structures like vectors and implemented mathematical techniques for efficient word frequency analysis and sentiment determination.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "enav754zxhuc9uycbb83s94q", - "visible": false, - "name": "Burger Ordering App", - "description": "", - "date": "UCLan Year 1", - "summary": "
  1. Interactive Console Application: Engineered a C++ console application simulating a burger ordering process, highlighting proficiency in creating user-interactive software.

  2. Complex Logic Implementation: Designed and implemented complex logic for burger size and topping selection, including pricing and order summary features.

  3. Data Handling and User Input: Developed robust credit system and user input validation for an intuitive ordering experience, showcasing attention to detail and user-centric design.

  4. Readable and Maintainable Code: Produced well-documented, maintainable code with clear variable naming and structured formatting, demonstrating best practices in software development.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "hl6jgeswr01tlul3iwoat05d", - "visible": false, - "name": "LinkLander", - "description": "Android Studio, Kotlin", - "date": "December 2023 - Ongoing", - "summary": "
  • Innovative Android Utility: Developed LinkLander, a Kotlin-based Android application that simplifies the process of downloading online content directly to devices.

  • User-Centric Design: Focused on addressing Android system limitations by providing a seamless shortcut for redirecting links to an online video downloading service.

  • Simplicity and Efficiency: Emphasized a user-friendly interface, enhancing the Android experience by streamlining content downloads.

  • Technical Proficiency in Kotlin: Leveraged the capabilities of Kotlin for Android development to create a practical solution for niche digital tasks.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "v4s0ljbiiio198y8l1wl0ym6", - "visible": false, - "name": "AR App Development with AGILE", - "description": "Unity, C#", - "date": "October 2023 - Ongoing", - "summary": "
  • Agile Development in Action: Participated in an Agile team project, developing an AR application for supporting disabled students with a team of five, demonstrating an application of Agile methodologies in a real-world scenario.

  • Mobile AR Application Prototype: Developed a proof-of-concept prototype using Unity and C# for mobile platforms, showcasing technical skills in modern app development environments.

  • Collaborative Software Engineering: Engaged in a collaborative environment, contributing code and ideas, emphasizing teamwork and shared responsibility in software creation.

  • Presentation and Critical Analysis: Delivered a comprehensive presentation and critical report, evaluating the Agile process, product development, and personal learning outcomes.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "fwxrq682hqrj1y76rmziqrbk", - "visible": true, - "name": "Indus Marine Services", - "description": "System Design & Development", - "date": "May 2022 - Ongoing", - "summary": "
  1. Induction System for Marine Services: Designed & developed an induction system for Indus Marine Services in the UAE, streamlining the employee onboarding process with interactive testing and certification issuance.

  2. Admin-Centric Functionality: Devised a back-end system allowing admins to oversee inductee progress, manage documents, and curate customized quizzes as per requirements

  3. Client Engagement Interface: Implemented a user-friendly front-end where inductees receive personalized email prompts, complete quizzes, and obtain certifications, all contributing to a seamless induction experience.

  4. Robust Tech Stack Integration: Utilized a sophisticated stack comprising Node.js, Express, EJS, and Tailwind CSS to build a responsive, scalable, and easily navigable system.

", - "keywords": [], - "url": { - "label": "", - "href": "http://www.ims-auh.com" - } - }, - { - "id": "jdfyaez8vq1b7xfr9rmxmz06", - "visible": false, - "name": "VECTOR AI", - "description": "Website Development", - "date": "February 2024 - February 2024", - "summary": "
  1. Innovative AI Development: As the driving force behind VECTOR's website development, I spearheaded the technical design using Astro, with a cutting-edge stack including React and Tailwind CSS.

  2. Data-Driven Content Strategy: Leveraged Astro content management capabilities to structure and present data, ensuring content is dynamic, easily accessible, and optimized for both performance and scalability.

  3. Astro for Enhanced Performance: Utilized Astro for static site generation, making VECTOR's website performance fast for a pleasant user experience

  4. React for Responsive Interaction: Utilized React’s robust ecosystem to develop interactive elements, ensuring that each module of VECTOR’s platform is engaging and seamless for users across various touchpoints.

", - "keywords": [], - "url": { - "label": "", - "href": "https://vector-ai.co/" - } - }, - { - "id": "qdhmfkqpfql19ohfas1g91ek", - "visible": false, - "name": "UCLan's First Hackathon", - "description": "Hackathon, Team Work", - "date": "February 2024", - "summary": "
  1. Second Place in UCLan Hackathon: Earned second place in UCLan's first hackathon by developing an app to simplify university life. Focused on enhancing the attendance monitoring process for student mentors.

  2. TRPC for End-to-End Type Safety: Utilized TRPC to ensure end-to-end type safety, enhancing the app's reliability and streamlining the development process.

  3. Supabase Backend Integration: Implemented Supabase as a backend solution, providing a robust and scalable database for managing attendance data efficiently.

  4. Amazon SES and OAuth Integration: Integrated Amazon SES for email notifications and OAuth for secure Google login, improving user experience and communication.

", - "keywords": [], - "url": { - "label": "", - "href": "" - } - }, - { - "id": "rw3x7tapntrt877rbl4pnxz7", - "visible": true, - "name": "NASA Space Apps Challenge", - "description": "A 48-hour, global hackathon powered by NASA open data", - "date": "Oct 4–5, 2025", - "summary": "
  1. Full-Stack Integration: Wired up backend services to a responsive frontend, enabling real-time exploration of Kepler/K2/TESS catalogs and smooth model-scoring UX.

  2. Data Harmonization Pipeline: Cleaned, merged, and standardized multi-mission catalogs into a unified schema, unblocking ML teammates and cutting data-prep time by 60%+ during the hack.

  3. Analytics UI & Upload Flow: Built an upload → validate → score workflow and a clear results dashboard so researchers can triage candidates in minutes, not hours.

  4. Delivery Under Pressure: Coordinated a 5-person multidisciplinary team to ship a working web app in 48 hours, with demo-ready reliability for judging.

", - "keywords": [], - "url": { - "label": "", - "href": "https://exploranium.vercel.app/dashboard" - } - }, - { - "id": "i2t6epmx5v7s0d8rqtxsigp3", - "visible": true, - "name": "Strong Statistics", - "description": "Self-hosted strength analytics app using FastAPI and Next.js to visualize Strong app data with full local privacy and active open-source adoption.", - "date": "September 2025 - Present", - "summary": "
  1. Self-Hosted Strength Analytics Platform: Developed strong-statistics, an open-source web app that visualizes detailed workout analytics from the Strong and Hevy fitness app, giving users local control of their training data.

  2. Full-Stack Architecture: Built a modular stack with FastAPI, Next.js, Tailwind CSS, and SQLite, deployed via Docker Compose for seamless self-hosting and persistent local data storage.

  3. Active Open-Source Ecosystem: Published on GitHub with community engagement from global users — external contributors opened feature requests and bug reports, validating real-world adoption and reliability.

  4. Continuous Personal Use & Maintenance: Regularly updated and used in live deployment at lifting.dakheera47.com, tracking hundreds of sets over time with persistent analytics and performance trends.

", - "keywords": [], - "url": { - "label": "", - "href": "https://lifting.dakheera47.com/" - } - } - ] - }, - "publications": { - "name": "Publications", - "columns": 1, - "separateLinks": true, - "visible": true, - "id": "publications", - "items": [] - }, - "references": { - "name": "References", - "columns": 1, - "separateLinks": true, - "visible": false, - "id": "references", - "items": [ - { - "id": "f2sv5z0cce6ztjl87yuk8fak", - "visible": true, - "name": "Available upon request", - "description": "", - "summary": "", - "url": { - "label": "", - "href": "" - } - } - ] - }, - "skills": { - "name": "Skills", - "columns": 2, - "separateLinks": true, - "visible": true, - "id": "skills", - "items": [ - { - "id": "jfgzfcwcg65k9gemuxlfe9m3", - "visible": true, - "name": "Frontend Development", - "description": "", - "level": 0, - "keywords": [ - "React", - "Next.js", - "Tailwind CSS", - "Strapi CMS", - "Elementor", - "GraphQL", - "TypeScript", - "CI/CD", - "PWA Development", - "AstroJS", - "React Testing Library" - ] - }, - { - "id": "sk3957foopxir2hw4xzxqahh", - "visible": true, - "name": "Backend Development", - "description": "", - "level": 0, - "keywords": [ - "Node.js", - "Express.js", - "MongoDB", - "Supabase", - "Firebase", - "Docker", - "FastAPI", - "AWS S3", - "AWS SES" - ] - }, - { - "id": "d9bddwdj6qreknhk644rm0bs", - "visible": true, - "name": "Leadership and Problem-Solving", - "description": "", - "level": 0, - "keywords": [ - "Agile Project Management", - "Conflict Resolution", - "Creative Problem-Solving", - "Decision-Making", - "Effective Communication", - "Adaptability" - ] - }, - { - "id": "gk4hrky0wnbsbdcmmud48zjh", - "visible": true, - "name": "Other Programming", - "description": "", - "level": 0, - "keywords": [ - "Python Scripting", - "PyAutoGUI", - "Git", - "GitHub", - "Selenium", - "Data Analysis", - "Web Scraping", - "Data Cleaning" - ] - } - ] - }, - "custom": {} - }, - "metadata": { - "template": "onyx", - "layout": [ - [ - [ - "summary", - "education", - "experience", - "projects", - "references" - ], - [ - "profiles", - "skills", - "certifications", - "interests", - "languages", - "awards", - "volunteer", - "publications" - ] - ] - ], - "css": { - "value": "* {\n\toutline: 1px solid #000;\n\toutline-offset: 4px;\n}", - "visible": false - }, - "page": { - "margin": 34, - "format": "a4", - "options": { - "breakLine": false, - "pageNumbers": false - } - }, - "theme": { - "background": "#ffffff", - "text": "#000000", - "primary": "#475569" - }, - "typography": { - "font": { - "family": "IBM Plex Sans", - "subset": "latin", - "variants": [ - "regular" - ], - "size": 13 - }, - "lineHeight": 1.75, - "hideIcons": false, - "underlineLinks": true - }, - "notes": "" - } -} \ No newline at end of file From 1934b424384aefdf4abed36d7ee7926abd9b3785 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 13:44:42 +0000 Subject: [PATCH 05/18] keep fallback to `base.json`, but tell user to use API key first --- .../components/ReactiveResumeSection.tsx | 2 +- .../src/server/pipeline/orchestrator.ts | 16 ++++++++---- .../src/server/services/resumeProjects.ts | 25 ++++++++----------- 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index b7567e0..30ea1b9 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -96,7 +96,7 @@ export const ReactiveResumeSection: React.FC = ({ - None (Fallback to local base.json) + None (No profile data will be loaded) {resumes.map((resume) => ( {resume.name} diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index 60884be..3a61930 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -553,17 +553,23 @@ export function getPipelineStatus(): { isRunning: boolean } { * Load the user profile from JSON file. */ async function loadProfile(profilePath: string): Promise> { - try { - const rxResumeBaseResumeId = await settingsRepo.getSetting('rxResumeBaseResumeId'); - if (rxResumeBaseResumeId) { + const rxResumeBaseResumeId = await settingsRepo.getSetting('rxResumeBaseResumeId'); + if (rxResumeBaseResumeId) { + try { const resume = await getResume(rxResumeBaseResumeId); return resume.data as Record; + } catch (error) { + console.error(`❌ Failed to load resume from Reactive Resume (${rxResumeBaseResumeId}):`, error); + throw new Error(`Failed to load profile from Reactive Resume (ID: ${rxResumeBaseResumeId}). Please check your API key and connection.`); } + } + try { const content = await readFile(profilePath, 'utf-8'); return JSON.parse(content); } catch (error) { - console.warn(`Failed to load profile from ${profilePath}, using empty object`, error); - return {}; + const message = `No local profile found at ${profilePath} and no Reactive Resume base ID is configured. Reactive Resume integration is required for tailoring.`; + console.error(`❌ ${message}`); + throw new Error(message); } } diff --git a/orchestrator/src/server/services/resumeProjects.ts b/orchestrator/src/server/services/resumeProjects.ts index a609ae1..b701432 100644 --- a/orchestrator/src/server/services/resumeProjects.ts +++ b/orchestrator/src/server/services/resumeProjects.ts @@ -13,28 +13,25 @@ export const DEFAULT_RESUME_PROFILE_PATH = type ResumeProjectSelectionItem = ResumeProjectCatalogItem & { summaryText: string }; export async function loadResumeProfile(profilePath: string = DEFAULT_RESUME_PROFILE_PATH): Promise { - try { - const rxResumeBaseResumeId = await getSetting('rxResumeBaseResumeId'); - if (rxResumeBaseResumeId) { + const rxResumeBaseResumeId = await getSetting('rxResumeBaseResumeId'); + + if (rxResumeBaseResumeId) { + try { const resume = await getResume(rxResumeBaseResumeId); return resume.data; + } catch (error) { + console.error(`❌ Failed to load resume from Reactive Resume (${rxResumeBaseResumeId}):`, error); + throw new Error(`Failed to load profile from Reactive Resume (ID: ${rxResumeBaseResumeId}). Please check your API key and connection.`); } + } + // Fallback to local file + try { 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 - } - } + console.warn(`⚠️ No local profile found at ${profilePath} and no Reactive Resume base ID is configured. Reactive Resume integration is required for tailoring.`); return {}; } } From e66a3de28384ab64115ab65fb32e996d4a3fb2e6 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 14:56:35 +0000 Subject: [PATCH 06/18] ensure skills always have the right spec --- orchestrator/src/server/services/pdf.ts | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 63c45b4..5697553 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -78,14 +78,24 @@ export async function generatePdf( // Inject tailored skills if (tailoredContent.skills) { - const newSkills = Array.isArray(tailoredContent.skills) + const rawSkills = Array.isArray(tailoredContent.skills) ? tailoredContent.skills : typeof tailoredContent.skills === 'string' ? JSON.parse(tailoredContent.skills) : null; - if (newSkills && resumeData.sections?.skills) { - resumeData.sections.skills.items = newSkills; + if (rawSkills && resumeData.sections?.skills) { + // Ensure each skill item has all required fields per OpenAPI spec + const normalizedSkills = rawSkills.map((skill: any, index: number) => ({ + id: skill.id || `skill-${index}-${Date.now()}`, + hidden: skill.hidden ?? false, + icon: skill.icon || '', + name: skill.name || '', + proficiency: skill.proficiency || '', + level: skill.level ?? 0, + keywords: Array.isArray(skill.keywords) ? skill.keywords : [], + })); + resumeData.sections.skills.items = normalizedSkills; } } @@ -131,14 +141,8 @@ export async function generatePdf( // 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, - }); + tempResumeId = await importResume(resumeData); if (!tempResumeId) { throw new Error('Failed to get ID for imported resume'); From d45b22f5df77642a72a67850b9a6e76071681efd Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Tue, 20 Jan 2026 21:34:17 +0000 Subject: [PATCH 07/18] temp: loading multiple apikeys from env --- orchestrator/src/server/services/pdf.ts | 8 +- orchestrator/src/server/services/rxresume.ts | 136 +++-- orchestrator/src/shared/rxresume-schema.ts | 527 +++++++++++++++++++ 3 files changed, 639 insertions(+), 32 deletions(-) create mode 100644 orchestrator/src/shared/rxresume-schema.ts diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 5697553..bdfaadf 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -141,8 +141,14 @@ export async function generatePdf( // 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(resumeData); + tempResumeId = await importResume({ + name: tempName, + slug: `temp-${jobId}-${timestamp}`, + data: resumeData, + }); if (!tempResumeId) { throw new Error('Failed to get ID for imported resume'); diff --git a/orchestrator/src/server/services/rxresume.ts b/orchestrator/src/server/services/rxresume.ts index fd26939..29aab9f 100644 --- a/orchestrator/src/server/services/rxresume.ts +++ b/orchestrator/src/server/services/rxresume.ts @@ -1,6 +1,4 @@ -/** - * Service for interacting with the Reactive Resume API. - */ +import { resumeDataSchema } from "../../shared/rxresume-schema"; export interface RxResumeResponse { id: string; @@ -11,14 +9,91 @@ export interface RxResumeResponse { } /** - * Generic fetch helper for Reactive Resume API + * Temporary helper to execute a fetch request with multiple API keys if in development. + * THIS FUNCTION IS TEMPORARY AND WILL BE REMOVED. */ -export async function fetchRxResume(path: string, options: RequestInit = {}): Promise { - const apiKey = process.env.RXRESUME_API_KEY; - if (!apiKey) { + +// Cache for last working key index (temporary, part of dev-only logic) +let lastWorkingKeyIndex = 0; + +async function executeWithKeyRetries(url: string, options: RequestInit): Promise { + const rawApiKey = process.env.RXRESUME_API_KEY; + if (!rawApiKey) { throw new Error('RXRESUME_API_KEY not configured in environment'); } + const isDev = process.env.NODE_ENV !== 'production'; + const apiKeys = (isDev && rawApiKey.includes(',')) + ? rawApiKey.split(',').map(k => k.trim()) + : [rawApiKey]; + + let lastError: Error | null = null; + + // Start from the last working key index + for (let attempt = 0; attempt < apiKeys.length; attempt++) { + const i = (lastWorkingKeyIndex + attempt) % apiKeys.length; + const apiKey = apiKeys[i]; + try { + const headers = { + 'x-api-key': apiKey, + ...(options.body ? { '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 })); + const errorMsg = `Reactive Resume API error (${response.status}): ${errorData.message || response.statusText}`; + + // ONLY retry/rotation on 401 Unauthorized + if (response.status === 401 && apiKeys.length > 1 && attempt < apiKeys.length - 1) { + console.warn(`[RxResume SDK] Key index ${i} was Unauthorized, trying next key...`); + continue; + } + + throw new Error(errorMsg); + } + + // Success! Cache this key index for future requests + lastWorkingKeyIndex = i; + + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } + return response.text(); + } catch (error) { + lastError = error as Error; + + // If it was already handled by the 401 check above, it won't reach here + // because of the 'continue'. This catch is for network errors or unexpected throw. + throw error; + } + } + + // Unmissable error block if all keys fail + if (apiKeys.length > 1) { + console.error(` +################################################################################ +# # +# ❌ ALL REACTIVE RESUME API KEYS FAILED (${apiKeys.length} keys attempted) # +# Please check your .env configuration. # +# # +################################################################################ +`); + } + + throw lastError || new Error('All Reactive Resume API keys failed.'); +} + +/** + * Generic fetch helper for Reactive Resume API + */ +export async function fetchRxResume(path: string, options: RequestInit = {}): Promise { const baseUrl = process.env.RXRESUME_URL || 'https://rxresu.me'; let cleanBaseUrl = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl; @@ -30,30 +105,7 @@ export async function fetchRxResume(path: string, options: RequestInit = {}): Pr } const url = `${cleanBaseUrl}/api/openapi${path}`; - - const headers = { - 'x-api-key': apiKey, - // intentionally removed because it doesn't work with this added... - // '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(); + return executeWithKeyRetries(url, options); } /** @@ -67,6 +119,28 @@ export async function getResume(id: string): Promise { * Import a resume. */ export async function importResume(payload: { name: string; slug: string; data: any }): Promise { + // Validate data against schema before sending + try { + payload.data = resumeDataSchema.parse(payload.data); + } catch (error) { + console.error("❌ Resume data validation failed:", error); + throw error; + } + + // DEBUG: Save payload to file for debugging (temporary) + try { + const fs = await import('fs/promises'); + const path = await import('path'); + const debugDir = path.join(process.cwd(), 'debug'); + await fs.mkdir(debugDir, { recursive: true }); + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const filename = path.join(debugDir, `rxresume-import-${timestamp}.json`); + await fs.writeFile(filename, JSON.stringify(payload, null, 2), 'utf-8'); + console.log(`📝 DEBUG: Saved import payload to ${filename}`); + } catch (debugErr) { + console.warn('⚠️ Could not save debug file:', debugErr); + } + const result = await fetchRxResume('/resume/import', { method: 'POST', body: JSON.stringify(payload), diff --git a/orchestrator/src/shared/rxresume-schema.ts b/orchestrator/src/shared/rxresume-schema.ts new file mode 100644 index 0000000..95ad09b --- /dev/null +++ b/orchestrator/src/shared/rxresume-schema.ts @@ -0,0 +1,527 @@ +import z from "zod"; + +export const templateSchema = z.enum([ + "azurill", + "bronzor", + "chikorita", + "ditto", + "ditgar", + "gengar", + "glalie", + "kakuna", + "lapras", + "leafish", + "onyx", + "pikachu", + "rhyhorn", +]); + +export type Template = z.infer; + +export const iconSchema = z + .string() + .describe( + "The icon to display for the custom field. Must be a valid icon name from @phosphor-icons/web icon set, or an empty string to hide. Default to '' (empty string) when unsure which icons are available.", + ); + +export const localeSchema = z + .union([ + z.literal("af-ZA"), + z.literal("am-ET"), + z.literal("ar-SA"), + z.literal("az-AZ"), + z.literal("bg-BG"), + z.literal("bn-BD"), + z.literal("ca-ES"), + z.literal("cs-CZ"), + z.literal("da-DK"), + z.literal("de-DE"), + z.literal("el-GR"), + z.literal("en-US"), + z.literal("es-ES"), + z.literal("fa-IR"), + z.literal("fi-FI"), + z.literal("fr-FR"), + z.literal("he-IL"), + z.literal("hi-IN"), + z.literal("hu-HU"), + z.literal("id-ID"), + z.literal("it-IT"), + z.literal("ja-JP"), + z.literal("km-KH"), + z.literal("kn-IN"), + z.literal("ko-KR"), + z.literal("lt-LT"), + z.literal("lv-LV"), + z.literal("ml-IN"), + z.literal("mr-IN"), + z.literal("ms-MY"), + z.literal("ne-NP"), + z.literal("nl-NL"), + z.literal("no-NO"), + z.literal("or-IN"), + z.literal("pl-PL"), + z.literal("pt-BR"), + z.literal("pt-PT"), + z.literal("ro-RO"), + z.literal("ru-RU"), + z.literal("sk-SK"), + z.literal("sq-AL"), + z.literal("sr-SP"), + z.literal("sv-SE"), + z.literal("ta-IN"), + z.literal("te-IN"), + z.literal("th-TH"), + z.literal("tr-TR"), + z.literal("uk-UA"), + z.literal("uz-UZ"), + z.literal("vi-VN"), + z.literal("zh-CN"), + z.literal("zh-TW"), + z.literal("zu-ZA"), + ]) + .describe("The language used in the resume, used for displaying pre-translated section headings, if not overridden.") + .catch("en-US"); + +export const urlSchema = z.object({ + url: z + .string() + .describe( + "The URL to show as a link. Must be a valid URL with a protocol (http:// or https://). Leave blank to hide.", + ), + label: z.string().describe("The label to display for the URL. Leave blank to display the URL as-is."), +}); + +export const pictureSchema = z.object({ + hidden: z.boolean().describe("Whether to hide the picture from the resume."), + url: z + .string() + .describe( + "The URL to the picture to display on the resume. Must be a valid URL with a protocol (http:// or https://). Leave blank to hide.", + ), + size: z + .number() + .min(32) + .max(512) + .describe("The size of the picture to display on the resume, defined in points (pt)."), + rotation: z + .number() + .min(0) + .max(360) + .describe("The rotation of the picture to display on the resume, defined in degrees (°)."), + aspectRatio: z + .number() + .min(0.5) + .max(2.5) + .describe( + "The aspect ratio of the picture to display on the resume, defined as width / height (e.g. 1.5 for 1.5:1 or 0.5 for 1:2).", + ), + borderRadius: z + .number() + .min(0) + .max(100) + .describe("The border radius of the picture to display on the resume, defined in points (pt)."), + borderColor: z + .string() + .describe("The color of the border of the picture to display on the resume, defined as rgba(r, g, b, a)."), + borderWidth: z + .number() + .min(0) + .describe("The width of the border of the picture to display on the resume, defined in points (pt)."), + shadowColor: z + .string() + .describe("The color of the shadow of the picture to display on the resume, defined as rgba(r, g, b, a)."), + shadowWidth: z + .number() + .min(0) + .describe("The width of the shadow of the picture to display on the resume, defined in points (pt)."), +}); + +export const customFieldSchema = z.object({ + id: z.string().describe("The unique identifier for the custom field. Usually generated as a UUID."), + icon: iconSchema, + text: z.string().describe("The text to display for the custom field."), +}); + +export const basicsSchema = z.object({ + name: z.string().describe("The full name of the author of the resume."), + headline: z.string().describe("The headline of the author of the resume."), + email: z.string().email().or(z.literal("")).describe("The email address of the author of the resume. Leave blank to hide."), + phone: z.string().describe("The phone number of the author of the resume. Leave blank to hide."), + location: z.string().describe("The location of the author of the resume."), + website: urlSchema.describe("The website of the author of the resume."), + customFields: z.array(customFieldSchema).describe("The custom fields to display on the resume."), +}); + +export const summarySchema = z.object({ + title: z.string().describe("The title of the summary of the resume."), + columns: z.number().describe("The number of columns the summary should span across."), + hidden: z.boolean().describe("Whether to hide the summary from the resume."), + content: z + .string() + .describe("The content of the summary of the resume. This should be a HTML-formatted string. Leave blank to hide."), +}); + +export const baseItemSchema = z.object({ + id: z.string().describe("The unique identifier for the item. Usually generated as a UUID."), + hidden: z.boolean().describe("Whether to hide the item from the resume."), +}); + +export const awardItemSchema = baseItemSchema.extend({ + title: z.string().min(1).describe("The title of the award."), + awarder: z.string().describe("The awarder of the award."), + date: z.string().describe("The date when the award was received."), + website: urlSchema.describe("The website of the award, if any."), + description: z + .string() + .describe("The description of the award. This should be a HTML-formatted string. Leave blank to hide."), +}); + +export const certificationItemSchema = baseItemSchema.extend({ + title: z.string().min(1).describe("The title of the certification."), + issuer: z.string().describe("The issuer of the certification."), + date: z.string().describe("The date when the certification was received."), + website: urlSchema.describe("The website of the certification, if any."), + description: z + .string() + .describe("The description of the certification. This should be a HTML-formatted string. Leave blank to hide."), +}); + +export const educationItemSchema = baseItemSchema.extend({ + school: z.string().min(1).describe("The name of the school or institution."), + degree: z.string().describe("The degree or qualification obtained."), + area: z.string().describe("The area of study or specialization."), + grade: z.string().describe("The grade or score achieved."), + location: z.string().describe("The location of the school or institution."), + period: z.string().describe("The period of time the education was obtained over."), + website: urlSchema.describe("The website of the school or institution, if any."), + description: z + .string() + .describe("The description of the education. This should be a HTML-formatted string. Leave blank to hide."), +}); + +export const experienceItemSchema = baseItemSchema.extend({ + company: z.string().min(1).describe("The name of the company or organization."), + position: z.string().describe("The position held at the company or organization."), + location: z.string().describe("The location of the company or organization."), + period: z.string().describe("The period of time the author was employed at the company or organization."), + website: urlSchema.describe("The website of the company or organization, if any."), + description: z + .string() + .describe("The description of the experience. This should be a HTML-formatted string. Leave blank to hide."), +}); + +export const interestItemSchema = baseItemSchema.extend({ + icon: iconSchema, + name: z.string().min(1).describe("The name of the interest/hobby."), + keywords: z + .array(z.string()) + .catch([]) + .describe("The keywords associated with the interest/hobby, if any. These are displayed as tags below the name."), +}); + +export const languageItemSchema = baseItemSchema.extend({ + language: z.string().min(1).describe("The name of the language the author knows."), + fluency: z + .string() + .describe( + "The fluency level of the language. Can be any text, such as 'Native', 'Fluent', 'Conversational', etc. or can also be a CEFR level (A1, A2, B1, B2, C1, C2).", + ), + level: z + .number() + .min(0) + .max(5) + .catch(0) + .describe( + "The proficiency level of the language, defined as a number between 0 and 5. If set to 0, the icons displaying the level will be hidden.", + ), +}); + +export const profileItemSchema = baseItemSchema.extend({ + icon: iconSchema, + network: z.string().min(1).describe("The name of the network or platform."), + username: z.string().describe("The username of the author on the network or platform."), + website: urlSchema.describe("The link to the profile of the author on the network or platform, if any."), +}); + +export const projectItemSchema = baseItemSchema.extend({ + name: z.string().min(1).describe("The name of the project."), + period: z.string().describe("The period of time the project was worked on."), + website: urlSchema.describe("The link to the project, if any."), + description: z + .string() + .describe("The description of the project. This should be a HTML-formatted string. Leave blank to hide."), +}); + +export const publicationItemSchema = baseItemSchema.extend({ + title: z.string().min(1).describe("The title of the publication."), + publisher: z.string().describe("The publisher of the publication."), + date: z.string().describe("The date when the publication was published."), + website: urlSchema.describe("The link to the publication, if any."), + description: z + .string() + .describe("The description of the publication. This should be a HTML-formatted string. Leave blank to hide."), +}); + +export const referenceItemSchema = baseItemSchema.extend({ + name: z.string().min(1).describe("The name of the reference, or a note such as 'Available upon request'."), + description: z + .string() + .describe( + "The description of the reference. Can be used to display a quote, a testimonial, etc. This should be a HTML-formatted string. Leave blank to hide.", + ), +}); + +export const skillItemSchema = baseItemSchema.extend({ + icon: iconSchema, + name: z.string().min(1).describe("The name of the skill."), + proficiency: z + .string() + .describe( + "The proficiency level of the skill. Can be any text, such as 'Beginner', 'Intermediate', 'Advanced', etc.", + ), + level: z + .number() + .min(0) + .max(5) + .catch(0) + .describe( + "The proficiency level of the skill, defined as a number between 0 and 5. If set to 0, the icons displaying the level will be hidden.", + ), + keywords: z + .array(z.string()) + .catch([]) + .describe("The keywords associated with the skill, if any. These are displayed as tags below the name."), +}); + +export const volunteerItemSchema = baseItemSchema.extend({ + organization: z.string().min(1).describe("The name of the organization or company."), + location: z.string().describe("The location of the organization or company."), + period: z.string().describe("The period of time the author was volunteered at the organization or company."), + website: urlSchema.describe("The link to the organization or company, if any."), + description: z + .string() + .describe( + "The description of the volunteer experience. This should be a HTML-formatted string. Leave blank to hide.", + ), +}); + +export const baseSectionSchema = z.object({ + title: z.string().describe("The title of the section."), + columns: z.number().describe("The number of columns the section should span across."), + hidden: z.boolean().describe("Whether to hide the section from the resume."), +}); + +export const awardsSectionSchema = baseSectionSchema.extend({ + items: z.array(awardItemSchema).describe("The items to display in the awards section."), +}); + +export const certificationsSectionSchema = baseSectionSchema.extend({ + items: z.array(certificationItemSchema).describe("The items to display in the certifications section."), +}); + +export const educationSectionSchema = baseSectionSchema.extend({ + items: z.array(educationItemSchema).describe("The items to display in the education section."), +}); + +export const experienceSectionSchema = baseSectionSchema.extend({ + items: z.array(experienceItemSchema).describe("The items to display in the experience section."), +}); + +export const interestsSectionSchema = baseSectionSchema.extend({ + items: z.array(interestItemSchema).describe("The items to display in the interests section."), +}); + +export const languagesSectionSchema = baseSectionSchema.extend({ + items: z.array(languageItemSchema).describe("The items to display in the languages section."), +}); + +export const profilesSectionSchema = baseSectionSchema.extend({ + items: z.array(profileItemSchema).describe("The items to display in the profiles section."), +}); + +export const projectsSectionSchema = baseSectionSchema.extend({ + items: z.array(projectItemSchema).describe("The items to display in the projects section."), +}); + +export const publicationsSectionSchema = baseSectionSchema.extend({ + items: z.array(publicationItemSchema).describe("The items to display in the publications section."), +}); + +export const referencesSectionSchema = baseSectionSchema.extend({ + items: z.array(referenceItemSchema).describe("The items to display in the references section."), +}); + +export const skillsSectionSchema = baseSectionSchema.extend({ + items: z.array(skillItemSchema).describe("The items to display in the skills section."), +}); + +export const volunteerSectionSchema = baseSectionSchema.extend({ + items: z.array(volunteerItemSchema).describe("The items to display in the volunteer section."), +}); + +export const sectionsSchema = z.object({ + profiles: profilesSectionSchema.describe("The section to display the profiles of the author."), + experience: experienceSectionSchema.describe("The section to display the experience of the author."), + education: educationSectionSchema.describe("The section to display the education of the author."), + projects: projectsSectionSchema.describe("The section to display the projects of the author."), + skills: skillsSectionSchema.describe("The section to display the skills of the author."), + languages: languagesSectionSchema.describe("The section to display the languages of the author."), + interests: interestsSectionSchema.describe("The section to display the interests of the author."), + awards: awardsSectionSchema.describe("The section to display the awards of the author."), + certifications: certificationsSectionSchema.describe("The section to display the certifications of the author."), + publications: publicationsSectionSchema.describe("The section to display the publications of the author."), + volunteer: volunteerSectionSchema.describe("The section to display the volunteer experience of the author."), + references: referencesSectionSchema.describe("The section to display the references of the author."), +}); + +export type SectionType = keyof z.infer; +export type SectionData = z.infer[T]; +export type SectionItem = SectionData["items"][number]; + +export const customSectionSchema = baseSectionSchema.extend({ + id: z.string().describe("The unique identifier for the custom section. Usually generated as a UUID."), + content: z + .string() + .describe("The content of the custom section. This should be a HTML-formatted string. Leave blank to hide."), +}); + +export type CustomSection = z.infer; + +export const customSectionsSchema = z.array(customSectionSchema); + +export const fontWeightSchema = z.enum(["100", "200", "300", "400", "500", "600", "700", "800", "900"]); + +export const typographyItemSchema = z.object({ + fontFamily: z.string().describe("The family of the font to use. Must be a font that is available on Google Fonts."), + fontWeights: z + .array(fontWeightSchema) + .catch(["400"]) + .describe( + "The weight of the font, defined as a number between 100 and 900. Default to 400 when unsure if the weight is available in the font.", + ), + fontSize: z.number().min(6).max(24).catch(11).describe("The size of the font to use, defined in points (pt)."), + lineHeight: z + .number() + .min(0.5) + .max(4) + .catch(1.5) + .describe("The line height of the font to use, defined as a multiplier of the font size (e.g. 1.5 for 1.5x)."), +}); + +export const pageLayoutSchema = z.object({ + fullWidth: z + .boolean() + .describe( + "Whether the layout of the page should be full width. If true, the main column will span the entire width of the page. This means that there should be no items in the sidebar column.", + ), + main: z + .array(z.string()) + .describe( + "The items to display in the main column of the page. A string array of section IDs (experience, education, projects, skills, languages, interests, awards, certifications, publications, volunteer, references, profiles, summary or UUIDs for custom sections).", + ), + sidebar: z + .array(z.string()) + .describe( + "The items to display in the sidebar column of the page. A string array of section IDs (experience, education, projects, skills, languages, interests, awards, certifications, publications, volunteer, references, profiles, summary or UUIDs for custom sections).", + ), +}); + +export const layoutSchema = z.object({ + sidebarWidth: z + .number() + .min(10) + .max(50) + .catch(35) + .describe("The width of the sidebar column, defined as a percentage of the page width."), + pages: z.array(pageLayoutSchema).describe("The pages to display in the layout."), +}); + +export const cssSchema = z.object({ + enabled: z.boolean().describe("Whether to enable custom CSS for the resume."), + value: z.string().describe("The custom CSS to apply to the resume. This should be a valid CSS string."), +}); + +export const pageSchema = z.object({ + gapX: z.number().min(0).describe("The horizontal gap between the sections of the page, defined in points (pt)."), + gapY: z.number().min(0).describe("The vertical gap between the sections of the page, defined in points (pt)."), + marginX: z.number().min(0).describe("The horizontal margin of the page, defined in points (pt)."), + marginY: z.number().min(0).describe("The vertical margin of the page, defined in points (pt)."), + format: z.enum(["a4", "letter"]).describe("The format of the page. Can be 'a4' or 'letter'."), + locale: localeSchema, + hideIcons: z.boolean().describe("Whether to hide the icons of the sections.").catch(false), +}); + +export const levelDesignSchema = z.object({ + icon: iconSchema, + type: z + .enum(["hidden", "circle", "square", "rectangle", "rectangle-full", "progress-bar", "icon"]) + .describe( + "The type of the level design. 'hidden' will hide the level design, 'circle' will display a circle, 'square' will display a square, 'rectangle' will display a rectangle, 'rectangle-full' will display a full rectangle, 'progress-bar' will display a progress bar, and 'icon' will display an icon. If 'icon' is selected, the icon to display should be specified in the 'icon' field.", + ), +}); + +export const colorDesignSchema = z.object({ + primary: z.string().describe("The primary color of the design, defined as rgba(r, g, b, a)."), + text: z + .string() + .describe("The text color of the design, defined as rgba(r, g, b, a). Usually set to black: rgba(0, 0, 0, 1)."), + background: z + .string() + .describe( + "The background color of the design, defined as rgba(r, g, b, a). Usually set to white: rgba(255, 255, 255, 1).", + ), +}); + +export const designSchema = z.object({ + level: levelDesignSchema, + colors: colorDesignSchema, +}); + +export const typographySchema = z.object({ + body: typographyItemSchema.describe("The typography for the body of the resume."), + heading: typographyItemSchema.describe("The typography for the headings of the resume."), +}); + +export const metadataSchema = z.object({ + template: templateSchema + .catch("onyx") + .describe("The template to use for the resume. Determines the overall design and appearance of the resume."), + layout: layoutSchema.describe( + "The layout of the resume. Determines the structure and arrangement of the sections on the resume.", + ), + css: cssSchema.describe( + "Custom CSS to apply to the resume. Can be used to override the default styles of the template.", + ), + page: pageSchema.describe( + "The page settings of the resume. Determines the margins, format, and locale of the resume.", + ), + design: designSchema.describe( + "The design settings of the resume. Determines the colors, level designs, and typography of the resume.", + ), + typography: typographySchema.describe( + "The typography settings of the resume. Determines the fonts and sizes of the body and headings of the resume.", + ), + notes: z + .string() + .describe( + "Personal notes for the resume. Can be used to add any additional information or instructions for the resume. These notes are not displayed on the resume, they are only visible to the author of the resume when editing the resume. This should be a HTML-formatted string.", + ), +}); + +export const resumeDataSchema = z.object({ + picture: pictureSchema.describe("Configuration for photograph displayed on the resume"), + basics: basicsSchema.describe( + "Basic information about the author, such as name, email, phone, location, and website", + ), + summary: summarySchema.describe("Summary section of the resume, useful for a short bio or introduction"), + sections: sectionsSchema.describe("Various sections of the resume, such as experience, education, projects, etc."), + customSections: customSectionsSchema.describe( + "Custom sections of the resume, such as a custom section for notes, etc.", + ), + metadata: metadataSchema.describe( + "Metadata for the resume, such as template, layout, typography, etc. This section describes the overall design and appearance of the resume.", + ), +}); + +export type ResumeData = z.infer; From 11aab30b111cb73e8996799b096244b08f2b8e4b Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Thu, 22 Jan 2026 22:59:01 +0000 Subject: [PATCH 08/18] ignore nodeModules --- orchestrator/vite.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/vite.config.ts b/orchestrator/vite.config.ts index 6632871..5b2c828 100644 --- a/orchestrator/vite.config.ts +++ b/orchestrator/vite.config.ts @@ -10,7 +10,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', setupFiles: './src/setupTests.ts', - exclude: ['dist/**'], + exclude: ['node_modules/**', 'dist/**'], }, resolve: { alias: { From 4798846483d7184afd9575a541fb0ace2b9fef12 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 00:55:44 +0000 Subject: [PATCH 09/18] v4 api based, with the same code facing api as v5 --- .../src/server/api/routes/settings.ts | 17 +- .../src/server/pipeline/orchestrator.ts | 8 +- .../services/pdf-skills-validation.test.ts | 123 ++++++--- .../src/server/services/pdf-tailoring.test.ts | 148 +++++++---- orchestrator/src/server/services/pdf.ts | 163 +++++++----- .../src/server/services/rxresume-client.ts | 242 +++++++++++++++++- .../src/server/services/rxresume-v4.ts | 105 ++++++++ .../services/{rxresume.ts => rxresume-v5.ts} | 7 + 8 files changed, 648 insertions(+), 165 deletions(-) create mode 100644 orchestrator/src/server/services/rxresume-v4.ts rename orchestrator/src/server/services/{rxresume.ts => rxresume-v5.ts} (95%) diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index ffd36c6..c299be1 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -11,7 +11,7 @@ import { } from '@server/services/resumeProjects.js'; import { getProfile } from '@server/services/profile.js'; import { getEffectiveSettings } from '@server/services/settings.js'; -import { listResumes } from '@server/services/rxresume.js'; +import { listResumes, RxResumeCredentialsError } from '@server/services/rxresume-v4.js'; export const settingsRouter = Router(); @@ -195,13 +195,24 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { }); /** - * GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume API + * GET /api/settings/rx-resumes - Fetch list of resumes from Reactive Resume v4 API */ settingsRouter.get('/rx-resumes', async (_req: Request, res: Response) => { try { const resumes = await listResumes(); - res.json({ success: true, data: { resumes } }); + + // Map to expected format (id, name) + res.json({ + success: true, + data: { + resumes: resumes.map((resume) => ({ id: resume.id, name: resume.name })), + }, + }); } catch (error) { + if (error instanceof RxResumeCredentialsError) { + res.status(400).json({ success: false, error: error.message }); + return; + } const message = error instanceof Error ? error.message : 'Unknown error'; console.error(`❌ Failed to fetch 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 f10398a..4715bed 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -7,15 +7,14 @@ * 3. Leave all jobs in "discovered" for manual processing */ -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; +import { join } from 'path'; import { runCrawler } from '../services/crawler.js'; import { runJobSpy } from '../services/jobspy.js'; import { runUkVisaJobs } from '../services/ukvisajobs.js'; import { scoreJobSuitability } from '../services/scorer.js'; import { generateTailoring } from '../services/summary.js'; import { generatePdf } from '../services/pdf.js'; -import { getProfile } from '../services/profile.js'; +import { DEFAULT_PROFILE_PATH, getProfile } from '../services/profile.js'; import { getSetting } from '../repositories/settings.js'; import { pickProjectIdsForJob } from '../services/projectSelection.js'; import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../services/resumeProjects.js'; @@ -27,9 +26,6 @@ import { progressHelpers, resetProgress, updateProgress } from './progress.js'; import type { CreateJobInput, Job, JobSource, PipelineConfig } from '../../shared/types.js'; import { getDataDir } from '../config/dataDir.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const DEFAULT_PROFILE_PATH = join(__dirname, '../../../../resume-generator/base.json'); - const DEFAULT_CONFIG: PipelineConfig = { topN: 10, minSuitabilityScore: 50, diff --git a/orchestrator/src/server/services/pdf-skills-validation.test.ts b/orchestrator/src/server/services/pdf-skills-validation.test.ts index 6ca353e..2fabd34 100644 --- a/orchestrator/src/server/services/pdf-skills-validation.test.ts +++ b/orchestrator/src/server/services/pdf-skills-validation.test.ts @@ -3,7 +3,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { generatePdf } from './pdf.js'; // Define mock data in hoisted block -const { mocks, mockProfile } = vi.hoisted(() => { +const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { const profile = { sections: { summary: { content: 'Original Summary' }, @@ -17,6 +17,24 @@ const { mocks, mockProfile } = vi.hoisted(() => { basics: { headline: 'Original Headline' } }; + // Capture what's passed to create() + let lastCreateData: any = null; + + const mockClient = { + create: vi.fn().mockImplementation((data: any) => { + lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone + return Promise.resolve('mock-resume-id'); + }), + print: vi.fn().mockResolvedValue('https://example.com/pdf/mock.pdf'), + delete: vi.fn().mockResolvedValue(undefined), + withAutoRefresh: vi.fn().mockImplementation(async (_email: string, _password: string, operation: (token: string) => Promise) => { + return operation('mock-token'); + }), + getToken: vi.fn().mockResolvedValue('mock-token'), + getLastCreateData: () => lastCreateData, + clearLastCreateData: () => { lastCreateData = null; }, + }; + return { mockProfile: profile, mocks: { @@ -25,7 +43,8 @@ const { mocks, mockProfile } = vi.hoisted(() => { mkdir: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), - } + }, + mockRxResumeClient: mockClient, }; }); @@ -42,11 +61,27 @@ vi.mock('fs/promises', async () => { vi.mock('fs', () => ({ existsSync: vi.fn().mockReturnValue(true), - default: { existsSync: vi.fn().mockReturnValue(true) } + createWriteStream: vi.fn().mockReturnValue({ + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + }), + default: { + existsSync: vi.fn().mockReturnValue(true), + createWriteStream: vi.fn().mockReturnValue({ + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + }), + } })); vi.mock('../repositories/settings.js', () => ({ - getSetting: vi.fn().mockResolvedValue(null), + getSetting: vi.fn().mockImplementation((key: string) => { + if (key === 'rxresumeEmail') return Promise.resolve('test@example.com'); + if (key === 'rxresumePassword') return Promise.resolve('testpassword'); + return Promise.resolve(null); + }), getAllSettings: vi.fn().mockResolvedValue({}), })); @@ -61,31 +96,50 @@ vi.mock('./resumeProjects.js', () => ({ }) })); -vi.mock('child_process', () => ({ - spawn: vi.fn().mockImplementation(() => ({ - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn().mockImplementation((event, cb) => { - if (event === 'close') cb(0); - return {}; - }), - })), - default: { - spawn: vi.fn().mockImplementation(() => ({ - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn().mockImplementation((event, cb) => { - if (event === 'close') cb(0); - return {}; - }), - })) +// Mock the RxResumeClient +vi.mock('./rxresume-client.js', () => ({ + RxResumeClient: class { + constructor() { + return mockRxResumeClient; + } } })); +// Mock stream pipeline for downloading PDF +vi.mock('stream/promises', () => ({ + pipeline: vi.fn().mockResolvedValue(undefined), + default: { + pipeline: vi.fn().mockResolvedValue(undefined), + } +})); + +// Mock stream Readable +vi.mock('stream', () => ({ + Readable: { + fromWeb: vi.fn().mockReturnValue({ + pipe: vi.fn(), + }), + }, + default: { + Readable: { + fromWeb: vi.fn().mockReturnValue({ + pipe: vi.fn(), + }), + }, + } +})); + +// Mock global fetch for PDF download +vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + body: {}, +})); + describe('PDF Service Skills Validation', () => { beforeEach(() => { vi.clearAllMocks(); mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); + mockRxResumeClient.clearLastCreateData(); }); it('should add required schema fields (visible, description) to new skills', async () => { @@ -99,9 +153,8 @@ describe('PDF Service Skills Validation', () => { await generatePdf('job-skills-1', tailoredContent, 'Job Desc'); - expect(mocks.writeFile).toHaveBeenCalled(); - const callArgs = mocks.writeFile.mock.calls[0]; - const savedResumeJson = JSON.parse(callArgs[1] as string); + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); const skillItems = savedResumeJson.sections.skills.items; @@ -146,9 +199,8 @@ describe('PDF Service Skills Validation', () => { // No tailoring, pass dummy path to bypass getProfile cache and use readFile mock await generatePdf('job-no-tailor', {}, 'Job Desc', 'dummy.json'); - expect(mocks.writeFile).toHaveBeenCalled(); - const callArgs = mocks.writeFile.mock.calls[0]; - const savedResumeJson = JSON.parse(callArgs[1] as string); + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); const item = savedResumeJson.sections.skills.items[0]; @@ -177,9 +229,8 @@ describe('PDF Service Skills Validation', () => { await generatePdf('job-cuid2-test', {}, 'Job Desc', 'dummy.json'); - expect(mocks.writeFile).toHaveBeenCalled(); - const callArgs = mocks.writeFile.mock.calls[0]; - const savedResumeJson = JSON.parse(callArgs[1] as string); + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); const skillItems = savedResumeJson.sections.skills.items; @@ -215,9 +266,8 @@ describe('PDF Service Skills Validation', () => { await generatePdf('job-no-skill-prefix', {}, 'Job Desc', 'dummy.json'); - expect(mocks.writeFile).toHaveBeenCalled(); - const callArgs = mocks.writeFile.mock.calls[0]; - const savedResumeJson = JSON.parse(callArgs[1] as string); + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); const skill = savedResumeJson.sections.skills.items[0]; @@ -245,9 +295,8 @@ describe('PDF Service Skills Validation', () => { await generatePdf('job-preserve-id', {}, 'Job Desc', 'dummy.json'); - expect(mocks.writeFile).toHaveBeenCalled(); - const callArgs = mocks.writeFile.mock.calls[0]; - const savedResumeJson = JSON.parse(callArgs[1] as string); + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); const skill = savedResumeJson.sections.skills.items[0]; diff --git a/orchestrator/src/server/services/pdf-tailoring.test.ts b/orchestrator/src/server/services/pdf-tailoring.test.ts index df187fe..3500de5 100644 --- a/orchestrator/src/server/services/pdf-tailoring.test.ts +++ b/orchestrator/src/server/services/pdf-tailoring.test.ts @@ -1,33 +1,53 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as projectSelection from './projectSelection.js'; +import { generatePdf } from './pdf.js'; // Define mock data in hoisted block -const { mocks, mockProfile } = vi.hoisted(() => { +const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { const profile = { sections: { summary: { content: 'Original Summary' }, skills: { items: ['Original Skill'] }, - projects: { + projects: { items: [ // Start with visible=true to test if they get hidden { id: 'p1', name: 'Project 1', visible: true }, { id: 'p2', name: 'Project 2', visible: true } - ] + ] } }, basics: { headline: 'Original Headline' } }; + // Capture what's passed to create() + let lastCreateData: any = null; + + const mockClient = { + create: vi.fn().mockImplementation((data: any) => { + lastCreateData = JSON.parse(JSON.stringify(data)); // Deep clone + return Promise.resolve('mock-resume-id'); + }), + print: vi.fn().mockResolvedValue('https://example.com/pdf/mock.pdf'), + delete: vi.fn().mockResolvedValue(undefined), + withAutoRefresh: vi.fn().mockImplementation(async (_email: string, _password: string, operation: (token: string) => Promise) => { + return operation('mock-token'); + }), + getToken: vi.fn().mockResolvedValue('mock-token'), + getLastCreateData: () => lastCreateData, + clearLastCreateData: () => { lastCreateData = null; }, + }; + return { mockProfile: profile, mocks: { - readFile: vi.fn(), + readFile: vi.fn(), writeFile: vi.fn(), mkdir: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), unlink: vi.fn().mockResolvedValue(undefined), - } + }, + mockRxResumeClient: mockClient, }; }); @@ -44,12 +64,28 @@ vi.mock('fs/promises', async () => { vi.mock('fs', () => ({ existsSync: vi.fn().mockReturnValue(true), - default: { existsSync: vi.fn().mockReturnValue(true) } + createWriteStream: vi.fn().mockReturnValue({ + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + }), + default: { + existsSync: vi.fn().mockReturnValue(true), + createWriteStream: vi.fn().mockReturnValue({ + on: vi.fn(), + write: vi.fn(), + end: vi.fn(), + }), + } })); vi.mock('../repositories/settings.js', () => ({ - getSetting: vi.fn().mockResolvedValue(null), - getAllSettings: vi.fn().mockResolvedValue({}), + getSetting: vi.fn().mockImplementation((key: string) => { + if (key === 'rxresumeEmail') return Promise.resolve('test@example.com'); + if (key === 'rxresumePassword') return Promise.resolve('testpassword'); + return Promise.resolve(null); + }), + getAllSettings: vi.fn().mockResolvedValue({}), })); vi.mock('./projectSelection.js', () => ({ @@ -73,75 +109,88 @@ vi.mock('./resumeProjects.js', () => ({ }) })); -vi.mock('child_process', () => ({ - spawn: vi.fn().mockImplementation(() => ({ - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn().mockImplementation((event, cb) => { - if (event === 'close') cb(0); - return {}; - }), - })), - default: { - spawn: vi.fn().mockImplementation(() => ({ - stdout: { on: vi.fn() }, - stderr: { on: vi.fn() }, - on: vi.fn().mockImplementation((event, cb) => { - if (event === 'close') cb(0); - return {}; - }), - })) +// Mock the RxResumeClient +vi.mock('./rxresume-client.js', () => ({ + RxResumeClient: class { + constructor() { + return mockRxResumeClient; + } } })); -import { generatePdf } from './pdf.js'; +// Mock stream pipeline for downloading PDF +vi.mock('stream/promises', () => ({ + pipeline: vi.fn().mockResolvedValue(undefined), + default: { + pipeline: vi.fn().mockResolvedValue(undefined), + } +})); + +// Mock stream Readable +vi.mock('stream', () => ({ + Readable: { + fromWeb: vi.fn().mockReturnValue({ + pipe: vi.fn(), + }), + }, + default: { + Readable: { + fromWeb: vi.fn().mockReturnValue({ + pipe: vi.fn(), + }), + }, + } +})); + + +// Mock global fetch +vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + body: {}, +})); describe('PDF Service Tailoring Logic', () => { beforeEach(() => { - vi.clearAllMocks(); - - // Reset default behaviors + vi.clearAllMocks(); mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); - mocks.writeFile.mockResolvedValue(undefined); + mockRxResumeClient.clearLastCreateData(); }); it('should use provided selectedProjectIds and BYPASS AI selection', async () => { const tailoredContent = { summary: 'New Sum', headline: 'New Head', skills: [] }; - + await generatePdf('job-1', tailoredContent, 'Job Desc', 'base.json', 'p2'); // 1. pickProjectIdsForJob should NOT be called expect(projectSelection.pickProjectIdsForJob).not.toHaveBeenCalled(); - // 2. Verify writeFile content - expect(mocks.writeFile).toHaveBeenCalled(); - const callArgs = mocks.writeFile.mock.calls[0]; - const savedResumeJson = JSON.parse(callArgs[1] as string); - + // 2. Verify create data content + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); + const projects = savedResumeJson.sections.projects.items; const p1 = projects.find((p: any) => p.id === 'p1'); const p2 = projects.find((p: any) => p.id === 'p2'); expect(p2.visible).toBe(true); - expect(p1.visible).toBe(false); + expect(p1.visible).toBe(false); // 3. Verify Summary Update const summary = savedResumeJson.sections.summary.content; - expect(summary).toBe('New Sum'); + expect(summary).toBe('New Sum'); }); it('should handle comma-separated project IDs correctly', async () => { await generatePdf('job-2', {}, 'desc', 'base.json', 'p1, p2 '); - expect(mocks.writeFile).toHaveBeenCalled(); - const callArgs = mocks.writeFile.mock.calls[0]; - const savedResumeJson = JSON.parse(callArgs[1] as string); + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); const projects = savedResumeJson.sections.projects.items; expect(projects.find((p: any) => p.id === 'p1').visible).toBe(true); expect(projects.find((p: any) => p.id === 'p2').visible).toBe(true); }); - + it('should fall back to AI selection if selectedProjectIds is null/undefined', async () => { // Setup AI selection mock for this test vi.mocked(projectSelection.pickProjectIdsForJob).mockResolvedValue(['p1']); @@ -149,18 +198,17 @@ describe('PDF Service Tailoring Logic', () => { await generatePdf('job-3', {}, 'desc', 'base.json', undefined); expect(projectSelection.pickProjectIdsForJob).toHaveBeenCalled(); - - expect(mocks.writeFile).toHaveBeenCalled(); - const callArgs = mocks.writeFile.mock.calls[0]; - const savedResumeJson = JSON.parse(callArgs[1] as string); - + + expect(mockRxResumeClient.create).toHaveBeenCalled(); + const savedResumeJson = mockRxResumeClient.getLastCreateData(); + const p1 = savedResumeJson.sections.projects.items.find((p: any) => p.id === 'p1'); const p2 = savedResumeJson.sections.projects.items.find((p: any) => p.id === 'p2'); expect(p1.visible).toBe(true); expect(p2.visible).toBe(false); - - const visibleCount = savedResumeJson.sections.projects.items.filter((p:any) => p.visible).length; + + const visibleCount = savedResumeJson.sections.projects.items.filter((p: any) => p.visible).length; expect(visibleCount).toBe(1); }); }); diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 9ee7847..29c7796 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -1,21 +1,22 @@ /** - * Service for generating PDF resumes using RxResume automation. + * Service for generating PDF resumes using RxResume v4 API. */ import { join } from 'path'; -import { readFile, writeFile, mkdir, access } from 'fs/promises'; -import { existsSync } from 'fs'; -import { spawn } from 'child_process'; +import { mkdir, access } from 'fs/promises'; +import { existsSync, createWriteStream } from 'fs'; import { createId } from '@paralleldrive/cuid2'; +import { pipeline } from 'stream/promises'; +import { Readable } from 'stream'; import { getSetting } from '../repositories/settings.js'; import { pickProjectIdsForJob } from './projectSelection.js'; import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js'; import { getDataDir } from '../config/dataDir.js'; import { getProfile } from './profile.js'; +import { RxResumeClient } from './rxresume-client.js'; const OUTPUT_DIR = join(getDataDir(), 'pdfs'); -const RESUME_GEN_DIR = process.env.RESUME_GEN_DIR || join(getDataDir(), '..', 'resume-generator'); export interface PdfResult { success: boolean; @@ -30,7 +31,63 @@ export interface TailoredPdfContent { } /** - * Generate a tailored PDF resume for a job using RxResume automation. + * Get RxResume credentials from environment variables or database settings. + */ +async function getCredentials(): Promise<{ email: string; password: string; baseUrl: string }> { + // First check environment variables + let email = process.env.RXRESUME_EMAIL || ''; + let password = process.env.RXRESUME_PASSWORD || ''; + const baseUrl = process.env.RXRESUME_URL || 'https://v4.rxresu.me'; + + // Fall back to database settings if env vars are not set + if (!email) { + email = (await getSetting('rxresumeEmail')) || ''; + } + if (!password) { + password = (await getSetting('rxresumePassword')) || ''; + } + + if (!email || !password) { + throw new Error( + 'RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD environment variables or configure them in settings.' + ); + } + + return { email, password, baseUrl }; +} + +/** + * Download a file from a URL and save it to a local path. + */ +async function downloadFile(url: string, outputPath: string): Promise { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to download PDF: HTTP ${response.status} ${response.statusText}`); + } + + if (!response.body) { + throw new Error('No response body from PDF download'); + } + + // Convert Web ReadableStream to Node readable + const nodeReadable = Readable.fromWeb(response.body as any); + const fileStream = createWriteStream(outputPath); + + await pipeline(nodeReadable, fileStream); +} + +/** + * Generate a tailored PDF resume for a job using the RxResume v4 API. + * + * Flow: + * 1. Prepare resume data with tailored content and project selection + * 2. Get auth token (uses cached token or logs in) + * 3. Import/create resume on RxResume + * 4. Request print to get PDF URL + * 5. Download PDF locally + * 6. Delete temporary resume from RxResume + * + * Token refresh is handled automatically on 401 errors. */ export async function generatePdf( jobId: string, @@ -39,7 +96,7 @@ export async function generatePdf( baseResumePath?: string, selectedProjectIds?: string | null ): Promise { - console.log(`📄 Generating PDF for job ${jobId}...`); + console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`); try { // Ensure output directory exists @@ -47,9 +104,13 @@ export async function generatePdf( await mkdir(OUTPUT_DIR, { recursive: true }); } + // Get credentials and initialize client + const { email, password, baseUrl } = await getCredentials(); + const client = new RxResumeClient(baseUrl); + // Read base resume const baseResume = baseResumePath - ? JSON.parse(await readFile(baseResumePath, 'utf-8')) + ? JSON.parse(await import('fs/promises').then(fs => fs.readFile(baseResumePath, 'utf-8'))) : JSON.parse(JSON.stringify(await getProfile())); // Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords) @@ -152,26 +213,47 @@ export async function generatePdf( 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)); + // Use withAutoRefresh to handle token caching and 401 retry automatically + const outputPath = join(OUTPUT_DIR, `resume_${jobId}.pdf`); - // Generate PDF using Python script - output directly to our data folder - const outputFilename = `resume_${jobId}.pdf`; - const outputPath = join(OUTPUT_DIR, outputFilename); + await client.withAutoRefresh(email, password, async (token) => { + let resumeId: string | null = null; - await runPythonPdfGenerator(tempResumePath, outputFilename, OUTPUT_DIR); + try { + // Create resume on RxResume + console.log(` 📤 Uploading resume to RxResume...`); + resumeId = await client.create(baseResume, token); + console.log(` ✅ Resume created with ID: ${resumeId}`); - // Cleanup temp file - try { - const { unlink } = await import('fs/promises'); - await unlink(tempResumePath); - } catch { - // Ignore cleanup errors - } + // Get PDF URL + console.log(` 🖨️ Requesting PDF generation...`); + const pdfUrl = await client.print(resumeId, token); + console.log(` ✅ PDF URL received: ${pdfUrl}`); - console.log(`✅ PDF generated: ${outputPath}`); + // Download PDF + console.log(` 📥 Downloading PDF...`); + await downloadFile(pdfUrl, outputPath); + console.log(` ✅ PDF saved to: ${outputPath}`); + // Cleanup: delete temporary resume from RxResume + console.log(` 🧹 Cleaning up temporary resume...`); + await client.delete(resumeId, token); + console.log(` ✅ Temporary resume deleted from RxResume`); + resumeId = null; + } finally { + // Attempt cleanup if resume was created but not deleted + if (resumeId) { + try { + console.log(` 🧹 Attempting cleanup of orphaned resume...`); + await client.delete(resumeId, token); + } catch { + console.warn(` ⚠️ Failed to cleanup orphaned resume ${resumeId}`); + } + } + } + }); + + console.log(`✅ PDF generated successfully: ${outputPath}`); return { success: true, pdfPath: outputPath }; } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; @@ -180,41 +262,6 @@ export async function generatePdf( } } -/** - * 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}`)); - } - }); - - child.on('error', reject); - }); -} - /** * Check if a PDF exists for a job. */ diff --git a/orchestrator/src/server/services/rxresume-client.ts b/orchestrator/src/server/services/rxresume-client.ts index ca15e14..a33d69e 100644 --- a/orchestrator/src/server/services/rxresume-client.ts +++ b/orchestrator/src/server/services/rxresume-client.ts @@ -1,11 +1,49 @@ // rxresume-client.ts -// Minimal client for https://v4.rxresu.me -// Currently only verifyCredentials is in use; other methods are reserved for future use. -// -// NOTE (critical): Credentials should never be hardcoded or logged. +// Low-level HTTP client for the RxResume v4 API. +// - Handles login, token caching, and cookie-based auth. +// - Used by rxresume-v4.ts to provide a higher-level service surface. +// - The v5 client should be a drop-in replacement in the future. + +import type { ResumeData } from '../../shared/rxresume-schema.js'; type AnyObj = Record; +const TOKEN_COOKIE_NAMES = [ + 'accessToken', + 'access_token', + 'token', + 'authToken', + 'auth_token', + 'Authentication', + 'Refresh', +]; + +function extractTokenFromCookies(rawCookies: string | string[] | null): string | null { + if (!rawCookies) return null; + const combined = Array.isArray(rawCookies) ? rawCookies.join('; ') : rawCookies; + for (const name of TOKEN_COOKIE_NAMES) { + const match = new RegExp(`${name}=([^;]+)`).exec(combined); + if (match?.[1]) return match[1]; + } + return null; +} + +function buildAuthHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + Cookie: `Authentication=${token}`, + }; +} + +export type RxResumeResume = { + id: string; + name: string; + title: string; + slug?: string; + data?: ResumeData; + [key: string]: unknown; +}; + export type VerifyResult = | { ok: true } | { @@ -17,8 +55,113 @@ export type VerifyResult = details?: unknown; }; +interface CachedToken { + token: string; + expiresAt: number; // Unix timestamp +} + +// Token cache: key is hash of baseURL + identifier +const tokenCache = new Map(); + +// Default token TTL: 50 minutes (JWT tokens typically expire in 1 hour) +const DEFAULT_TOKEN_TTL_MS = 50 * 60 * 1000; + export class RxResumeClient { - constructor(private readonly baseURL = 'https://v4.rxresu.me') { } + private readonly tokenTtlMs: number; + + constructor( + private readonly baseURL = 'https://v4.rxresu.me', + options?: { tokenTtlMs?: number } + ) { + this.tokenTtlMs = options?.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS; + } + + /** + * Generate a cache key for token storage. + * Uses a simple hash of baseURL + identifier. + */ + private getCacheKey(identifier: string): string { + return `${this.baseURL}:${identifier}`; + } + + /** + * Get a valid auth token, using cached token if available and not expired. + * This is the preferred way to get a token for API calls. + */ + async getToken(identifier: string, password: string): Promise { + const cacheKey = this.getCacheKey(identifier); + const cached = tokenCache.get(cacheKey); + + // Return cached token if it exists and hasn't expired + if (cached && cached.expiresAt > Date.now()) { + return cached.token; + } + + // Login to get a new token + const token = await this.login(identifier, password); + + // Cache the token + tokenCache.set(cacheKey, { + token, + expiresAt: Date.now() + this.tokenTtlMs, + }); + + return token; + } + + /** + * Clear cached token for a specific identifier. + * Useful when a token becomes invalid (e.g., 401 response). + */ + clearCachedToken(identifier: string): void { + const cacheKey = this.getCacheKey(identifier); + tokenCache.delete(cacheKey); + } + + /** + * Clear all cached tokens. + */ + static clearAllCachedTokens(): void { + tokenCache.clear(); + } + + /** + * Execute an API operation with automatic token refresh on 401. + * If the operation fails with a 401, clears the cached token, gets a new one, and retries once. + * + * @param identifier - The user identifier (email) + * @param password - The user password + * @param operation - A function that takes a token and performs the API call + * @returns The result of the operation + */ + async withAutoRefresh( + identifier: string, + password: string, + operation: (token: string) => Promise + ): Promise { + const token = await this.getToken(identifier, password); + + try { + return await operation(token); + } catch (error) { + // Check if this is a 401 error + const message = error instanceof Error ? error.message : ''; + const isAuthError = + /HTTP\s*401/i.test(message) || + /Unauthorized/i.test(message) || + /Unauthenticated/i.test(message); + + if (isAuthError) { + // Clear the cached token and retry with a fresh one + this.clearCachedToken(identifier); + const freshToken = await this.getToken(identifier, password); + return await operation(freshToken); + } + + // Re-throw non-401 errors + throw error; + } + } /** * Verify a username/password combo WITHOUT persisting a logged-in session. @@ -98,13 +241,19 @@ export class RxResumeClient { const data = (await res.json()) as AnyObj; // The API may return the token in different ways - const token = + let token = data?.accessToken ?? data?.access_token ?? data?.token ?? (data?.data as AnyObj)?.accessToken ?? (data?.data as AnyObj)?.token; + if (!token) { + const setCookieHeader = res.headers.get('set-cookie'); + const setCookieArray = (res.headers as any).getSetCookie?.() as string[] | undefined; + token = extractTokenFromCookies(setCookieArray ?? setCookieHeader); + } + if (!token || typeof token !== 'string') { throw new Error( `Login succeeded but could not locate access token in response. Response keys: ${Object.keys(data).join(', ')}` @@ -117,15 +266,22 @@ export class RxResumeClient { /** * POST /api/resume/import */ - async create(resumeData: unknown, token: string): Promise { + async create( + resumeData: unknown, + token: string, + options?: { title?: string; slug?: string } + ): Promise { + const payload: AnyObj = { data: resumeData }; + if (options?.title) payload.title = options.title; + if (options?.slug) payload.slug = options.slug; const res = await fetch(`${this.baseURL}/api/resume/import`, { method: 'POST', headers: { Accept: 'application/json, text/plain, */*', 'Content-Type': 'application/json', - Authorization: `Bearer ${token}`, + ...buildAuthHeaders(token), }, - body: JSON.stringify({ data: resumeData }), + body: JSON.stringify(payload), }); if (!res.ok) { @@ -162,7 +318,7 @@ export class RxResumeClient { method: 'GET', headers: { Accept: 'application/json, text/plain, */*', - Authorization: `Bearer ${token}`, + ...buildAuthHeaders(token), }, } ); @@ -200,7 +356,7 @@ export class RxResumeClient { method: 'DELETE', headers: { Accept: 'application/json, text/plain, */*', - Authorization: `Bearer ${token}`, + ...buildAuthHeaders(token), }, } ); @@ -210,4 +366,68 @@ export class RxResumeClient { throw new Error(`Delete failed: HTTP ${res.status} ${text}`); } } + + private normalizeResume(raw: AnyObj): RxResumeResume { + const id = typeof raw.id === 'string' ? raw.id : ''; + const title = typeof raw.title === 'string' + ? raw.title + : typeof raw.name === 'string' + ? raw.name + : 'Untitled'; + const name = typeof raw.name === 'string' ? raw.name : title; + const slug = typeof raw.slug === 'string' ? raw.slug : undefined; + const data = raw.data && typeof raw.data === 'object' ? (raw.data as ResumeData) : undefined; + + return { + ...raw, + id, + title, + name, + slug, + data, + }; + } + + /** + * GET /api/resume + * List all resumes for the authenticated user. + */ + async list(token: string): Promise { + const res = await fetch(`${this.baseURL}/api/resume`, { + method: 'GET', + headers: { + Accept: 'application/json, text/plain, */*', + ...buildAuthHeaders(token), + }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`List resumes failed: HTTP ${res.status} ${text}`); + } + + const data = (await res.json()) as AnyObj | AnyObj[]; + + // API may return array directly or wrapped in data/resumes + const resumes = Array.isArray(data) + ? data + : (data?.data as AnyObj[]) ?? (data?.resumes as AnyObj[]) ?? []; + + return resumes + .filter((resume) => resume && typeof resume === 'object') + .map((resume) => this.normalizeResume(resume as AnyObj)); + } + + /** + * GET /api/resume + * Fetch a single resume by ID (via list filtering). + */ + async get(resumeId: string, token: string): Promise { + const resumes = await this.list(token); + const resume = resumes.find((item) => item.id === resumeId); + if (!resume) { + throw new Error(`Resume not found: ${resumeId}`); + } + return resume; + } } diff --git a/orchestrator/src/server/services/rxresume-v4.ts b/orchestrator/src/server/services/rxresume-v4.ts new file mode 100644 index 0000000..77b3ab5 --- /dev/null +++ b/orchestrator/src/server/services/rxresume-v4.ts @@ -0,0 +1,105 @@ +// rxresume-v4.ts +// Service wrapper around the v4 client that mirrors the v5 helper API. +// - Pulls credentials from env/settings. +// - Validates resume payloads. +// - Keeps the rest of the app v5-ready (swap imports later). + +import { resumeDataSchema } from '../../shared/rxresume-schema.js'; +import type { ResumeData } from '../../shared/rxresume-schema.js'; +import { RxResumeClient, type RxResumeResume } from './rxresume-client.js'; +import { getSetting } from '../repositories/settings.js'; + +export type RxResumeCredentials = { + email: string; + password: string; + baseUrl: string; +}; + +export type RxResumeImportPayload = { + name?: string; + slug?: string; + data: ResumeData; +}; + +export class RxResumeCredentialsError extends Error { + constructor() { + super( + 'RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in environment or settings.' + ); + this.name = 'RxResumeCredentialsError'; + } +} + +async function resolveRxResumeCredentials( + override?: Partial +): Promise { + const baseUrlRaw = override?.baseUrl ?? process.env.RXRESUME_URL ?? 'https://v4.rxresu.me'; + const baseUrl = baseUrlRaw.trim() || 'https://v4.rxresu.me'; + const overrideEmail = override?.email?.trim() ?? ''; + const overridePassword = override?.password?.trim() ?? ''; + + let email = overrideEmail || process.env.RXRESUME_EMAIL || ''; + let password = overridePassword || process.env.RXRESUME_PASSWORD || ''; + + if (!email) { + email = (await getSetting('rxresumeEmail')) || ''; + } + if (!password) { + password = (await getSetting('rxresumePassword')) || ''; + } + + if (!email || !password) { + throw new RxResumeCredentialsError(); + } + + return { email, password, baseUrl }; +} + +async function withRxResumeClient( + override: Partial | undefined, + operation: (client: RxResumeClient, token: string) => Promise +): Promise { + const { email, password, baseUrl } = await resolveRxResumeCredentials(override); + const client = new RxResumeClient(baseUrl); + return client.withAutoRefresh(email, password, (token) => operation(client, token)); +} + +export async function listResumes( + override?: Partial +): Promise { + return withRxResumeClient(override, (client, token) => client.list(token)); +} + +export async function getResume( + resumeId: string, + override?: Partial +): Promise { + return withRxResumeClient(override, (client, token) => client.get(resumeId, token)); +} + +export async function importResume( + payload: RxResumeImportPayload, + override?: Partial +): Promise { + const data = resumeDataSchema.parse(payload.data); + const title = payload.name?.trim() || undefined; + const slug = payload.slug?.trim() || undefined; + + return withRxResumeClient(override, (client, token) => + client.create(data, token, { title, slug }) + ); +} + +export async function deleteResume( + resumeId: string, + override?: Partial +): Promise { + return withRxResumeClient(override, (client, token) => client.delete(resumeId, token)); +} + +export async function exportResumePdf( + resumeId: string, + override?: Partial +): Promise { + return withRxResumeClient(override, (client, token) => client.print(resumeId, token)); +} diff --git a/orchestrator/src/server/services/rxresume.ts b/orchestrator/src/server/services/rxresume-v5.ts similarity index 95% rename from orchestrator/src/server/services/rxresume.ts rename to orchestrator/src/server/services/rxresume-v5.ts index 4d18865..b11b908 100644 --- a/orchestrator/src/server/services/rxresume.ts +++ b/orchestrator/src/server/services/rxresume-v5.ts @@ -1,3 +1,10 @@ +// rxresume-v5.ts +// Future-facing v5/OpenAPI implementation that uses API keys. +// - Kept alongside v4 files so we can swap imports when v5 is ready. +// - Uses RXRESUME_API_KEY and /api/openapi endpoints. +// +// NOTE: Not currently wired in; keep for migration. + import { resumeDataSchema } from "../../shared/rxresume-schema.js"; export interface RxResumeResponse { From 7a358db3170f4c64f6d07e4c49fb23a2cee81645 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 11:06:25 +0000 Subject: [PATCH 10/18] settings page can pull and save resume details from v4 api template --- orchestrator/src/client/api/client.ts | 7 + .../src/client/components/OnboardingGate.tsx | 135 ++------------ .../src/client/pages/SettingsPage.tsx | 122 ++++++++++++- .../components/ReactiveResumeSection.tsx | 172 ++++++++++++++++-- .../components/ResumeProjectsSection.test.tsx | 94 ---------- .../components/ResumeProjectsSection.tsx | 161 ---------------- .../src/server/api/routes/settings.ts | 63 ++++++- .../src/server/repositories/settings.ts | 1 + orchestrator/src/server/services/settings.ts | 32 +++- orchestrator/src/shared/settings-schema.ts | 3 +- orchestrator/src/shared/types.ts | 1 + 11 files changed, 386 insertions(+), 405 deletions(-) delete mode 100644 orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx delete mode 100644 orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 69a6417..f1dafcf 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -247,6 +247,13 @@ export async function getRxResumes(): Promise<{ id: string; name: string }[]> { return data.resumes; } +export async function getRxResumeProjects(resumeId: string): Promise { + const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>( + `/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects` + ); + return data.projects; +} + // Database API export async function clearDatabase(): Promise<{ diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index c144a64..46e0474 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -1,11 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import React, { useCallback, useEffect, useMemo, useState } from "react" import { Check } from "lucide-react" import { toast } from "sonner" import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" import { Button } from "@/components/ui/button" import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field" -import { Input } from "@/components/ui/input" import { Progress } from "@/components/ui/progress" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { cn } from "@/lib/utils" @@ -13,17 +12,15 @@ import * as api from "@client/api" import { useSettings } from "@client/hooks/useSettings" import { SettingsInput } from "@client/pages/settings/components/SettingsInput" import { formatSecretHint } from "@client/pages/settings/utils" -import type { ResumeProfile, ValidationResult } from "@shared/types" +import type { ValidationResult } from "@shared/types" type ValidationState = ValidationResult & { checked: boolean } export const OnboardingGate: React.FC = () => { const { settings, isLoading: settingsLoading, refreshSettings } = useSettings() const [isSavingEnv, setIsSavingEnv] = useState(false) - const [isUploadingResume, setIsUploadingResume] = useState(false) const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false) const [isValidatingRxresume, setIsValidatingRxresume] = useState(false) - const [isValidatingResume, setIsValidatingResume] = useState(false) const [openrouterValidation, setOpenrouterValidation] = useState({ valid: false, message: null, @@ -34,34 +31,11 @@ export const OnboardingGate: React.FC = () => { message: null, checked: false, }) - const [resumeValidation, setResumeValidation] = useState({ - valid: false, - message: null, - checked: false, - }) const [currentStep, setCurrentStep] = useState(null) const [openrouterApiKey, setOpenrouterApiKey] = useState("") const [rxresumeEmail, setRxresumeEmail] = useState("") const [rxresumePassword, setRxresumePassword] = useState("") - const [resumeFile, setResumeFile] = useState(null) - const fileInputRef = useRef(null) - - const validateResume = useCallback(async () => { - setIsValidatingResume(true) - try { - const result = await api.validateResumeJson() - setResumeValidation({ ...result, checked: true }) - return result - } catch (error) { - const message = error instanceof Error ? error.message : "Resume validation failed" - const result = { valid: false, message } - setResumeValidation({ ...result, checked: true }) - return result - } finally { - setIsValidatingResume(false) - } - }, []) const validateOpenrouter = useCallback(async (apiKey?: string) => { setIsValidatingOpenrouter(true) @@ -98,10 +72,8 @@ export const OnboardingGate: React.FC = () => { const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint) const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()) const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint) - const hasBaseResume = resumeValidation.valid - const shouldOpen = Boolean(settings && !settingsLoading) - && !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid) + && !(openrouterValidation.valid && rxresumeValidation.valid) const openrouterCurrent = settings?.openrouterApiKeyHint ? formatSecretHint(settings.openrouterApiKeyHint) @@ -127,14 +99,8 @@ export const OnboardingGate: React.FC = () => { subtitle: "RxResume login", complete: rxresumeValidation.valid, }, - { - id: "resume", - label: "Resume JSON", - subtitle: "Upload your file", - complete: resumeValidation.valid, - }, ], - [openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.valid] + [openrouterValidation.valid, rxresumeValidation.valid] ) const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id @@ -151,7 +117,6 @@ export const OnboardingGate: React.FC = () => { const results = await Promise.allSettled([ validateOpenrouter(), validateRxresume(), - validateResume(), ]) const failed = results.find((result) => result.status === "rejected") @@ -160,13 +125,13 @@ export const OnboardingGate: React.FC = () => { const message = reason instanceof Error ? reason.message : "Validation checks failed" toast.error(message) } - }, [settings, validateOpenrouter, validateRxresume, validateResume]) + }, [settings, validateOpenrouter, validateRxresume]) useEffect(() => { if (!settings || settingsLoading) return - if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return + if (openrouterValidation.checked || rxresumeValidation.checked) return void runAllValidations() - }, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations]) + }, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, runAllValidations]) const handleRefresh = async () => { const results = await Promise.allSettled([refreshSettings(), runAllValidations()]) @@ -254,58 +219,17 @@ export const OnboardingGate: React.FC = () => { } } - const handleUploadResume = async (): Promise => { - if (!resumeFile) { - const validation = await validateResume() - if (!validation.valid) { - toast.info(validation.message || "Upload your resume JSON to continue") - return false - } - - return true - } - - try { - setIsUploadingResume(true) - const text = await resumeFile.text() - let parsed: ResumeProfile - try { - parsed = JSON.parse(text) as ResumeProfile - } catch { - throw new Error("Resume JSON is invalid. Export the base.json from RxResume.") - } - - await api.uploadProfile(parsed) - await validateResume() - setResumeFile(null) - if (fileInputRef.current) { - fileInputRef.current.value = "" - } - toast.success("Resume uploaded") - return true - } catch (error) { - const message = error instanceof Error ? error.message : "Failed to upload resume" - toast.error(message) - return false - } finally { - setIsUploadingResume(false) - } - } - - const resumeFileName = resumeFile?.name || "" const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0 const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0 const completedSteps = steps.filter((step) => step.complete).length const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0 - const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume + const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume const canGoBack = stepIndex > 0 - const primaryLabel = currentStep === "resume" - ? (resumeValidation.valid ? "Finish" : "Upload and validate") - : currentStep === "openrouter" - ? (openrouterValidation.valid ? "Revalidate" : "Validate") - : currentStep === "rxresume" - ? (rxresumeValidation.valid ? "Revalidate" : "Validate") - : "Validate" + const primaryLabel = currentStep === "openrouter" + ? (openrouterValidation.valid ? "Revalidate" : "Validate") + : currentStep === "rxresume" + ? (rxresumeValidation.valid ? "Revalidate" : "Validate") + : "Validate" const handlePrimaryAction = async () => { if (!currentStep) return @@ -317,13 +241,6 @@ export const OnboardingGate: React.FC = () => { await handleSaveRxresume() return } - if (currentStep === "resume") { - if (hasBaseResume) { - await handleRefresh() - return - } - await handleUploadResume() - } } const handleBack = () => { @@ -348,7 +265,7 @@ export const OnboardingGate: React.FC = () => { - + {steps.map((step, index) => { const isActive = step.id === currentStep const isComplete = step.complete @@ -439,30 +356,6 @@ export const OnboardingGate: React.FC = () => {
- -
-

Upload your resume JSON

-

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

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

Selected: {resumeFileName}

- )} -
-
-
diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index 1df8f43..a286d4a 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -7,7 +7,7 @@ import { zodResolver } from "@hookform/resolvers/zod" import { PageHeader } from "@client/components/layout" import { Accordion } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" -import type { AppSettings, JobStatus } from "@shared/types" +import type { AppSettings, JobStatus, ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types" import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema" import * as api from "@client/api" import { arraysEqual } from "@/lib/utils" @@ -19,9 +19,9 @@ import { GradcrackerSection } from "@client/pages/settings/components/Gradcracke import { JobspySection } from "@client/pages/settings/components/JobspySection" import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection" import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection" -import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection" import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection" import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection" +import { ReactiveResumeSection } from "@client/pages/settings/components/ReactiveResumeSection" const DEFAULT_FORM_VALUES: UpdateSettingsInput = { model: "", @@ -31,6 +31,7 @@ const DEFAULT_FORM_VALUES: UpdateSettingsInput = { pipelineWebhookUrl: "", jobCompleteWebhookUrl: "", resumeProjects: null, + rxresumeBaseResumeId: null, ukvisajobsMaxJobs: null, gradcrackerMaxJobsPerTerm: null, searchTerms: null, @@ -60,6 +61,7 @@ const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { pipelineWebhookUrl: null, jobCompleteWebhookUrl: null, resumeProjects: null, + rxresumeBaseResumeId: null, ukvisajobsMaxJobs: null, gradcrackerMaxJobsPerTerm: null, searchTerms: null, @@ -89,6 +91,7 @@ const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "", jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "", resumeProjects: data.resumeProjects, + rxresumeBaseResumeId: data.rxresumeBaseResumeId ?? null, ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs, gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm, searchTerms: data.overrideSearchTerms, @@ -139,6 +142,35 @@ const nullIfSame = (value: T | null | undefined, defaultValue: T) => const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) => isSameStringList(value, defaultValue) ? null : value ?? null +const normalizeResumeProjectsForCatalog = ( + catalog: ResumeProjectCatalogItem[], + current: ResumeProjectsSettings | null +): ResumeProjectsSettings | null => { + const allowed = new Set(catalog.map((project) => project.id)) + + const base = current ?? { + maxProjects: 0, + lockedProjectIds: catalog.filter((project) => project.isVisibleInBase).map((project) => project.id), + aiSelectableProjectIds: [], + } + + const lockedProjectIds = base.lockedProjectIds.filter((id) => allowed.has(id)) + const lockedSet = new Set(lockedProjectIds) + const aiSelectableProjectIds = (base.aiSelectableProjectIds.length + ? base.aiSelectableProjectIds + : catalog.map((project) => project.id) + ) + .filter((id) => allowed.has(id)) + .filter((id) => !lockedSet.has(id)) + const maxProjectsRaw = Number.isFinite(base.maxProjects) ? base.maxProjects : 0 + const maxProjectsInt = Math.max(0, Math.floor(maxProjectsRaw)) + const maxProjects = Math.min( + catalog.length, + Math.max(lockedProjectIds.length, maxProjectsInt, 3) + ) + return { maxProjects, lockedProjectIds, aiSelectableProjectIds } +} + const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) => isSameSortedStringList(value, defaultValue) ? null : value ?? null @@ -230,6 +262,9 @@ export const SettingsPage: React.FC = () => { const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) const [statusesToClear, setStatusesToClear] = useState(['discovered']) + const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState(null) + const [rxResumeProjectsOverride, setRxResumeProjectsOverride] = useState(null) + const [isFetchingRxResumeProjects, setIsFetchingRxResumeProjects] = useState(false) const methods = useForm({ resolver: zodResolver(updateSettingsSchema), @@ -237,7 +272,19 @@ export const SettingsPage: React.FC = () => { defaultValues: DEFAULT_FORM_VALUES, }) - const { handleSubmit, reset, setError, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods + const { + handleSubmit, + reset, + setError, + setValue, + getValues, + watch, + formState: { isDirty, errors, isValid, dirtyFields } + } = methods + + const hasRxResumeAccess = Boolean( + settings?.rxresumeEmail?.trim() && settings?.rxresumePasswordHint + ) useEffect(() => { let isMounted = true @@ -263,6 +310,58 @@ export const SettingsPage: React.FC = () => { } }, [reset]) + useEffect(() => { + if (!settings) return + const storedId = settings.rxresumeBaseResumeId ?? null + setRxResumeBaseResumeIdDraft(storedId) + setValue("rxresumeBaseResumeId", storedId, { shouldDirty: false }) + setRxResumeProjectsOverride(null) + }, [settings, setValue]) + + useEffect(() => { + let isMounted = true + + if (!rxResumeBaseResumeIdDraft) { + setRxResumeProjectsOverride(null) + return () => { + isMounted = false + } + } + + if (!hasRxResumeAccess) return () => { + isMounted = false + } + + setIsFetchingRxResumeProjects(true) + api + .getRxResumeProjects(rxResumeBaseResumeIdDraft) + .then((projects) => { + if (!isMounted) return + setRxResumeProjectsOverride(projects) + const normalized = normalizeResumeProjectsForCatalog( + projects, + getValues("resumeProjects") ?? null + ) + if (normalized) { + setValue("resumeProjects", normalized, { shouldDirty: true }) + } + }) + .catch((error) => { + if (!isMounted) return + const message = error instanceof Error ? error.message : "Failed to load RxResume projects" + toast.error(message) + setRxResumeProjectsOverride(null) + }) + .finally(() => { + if (!isMounted) return + setIsFetchingRxResumeProjects(false) + }) + + return () => { + isMounted = false + } + }, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]) + const derived = getDerivedSettings(settings) const { model, @@ -279,6 +378,9 @@ export const SettingsPage: React.FC = () => { maxProjectsTotal, } = derived + const effectiveProfileProjects = rxResumeProjectsOverride ?? profileProjects + const effectiveMaxProjectsTotal = effectiveProfileProjects.length + const watchedValues = watch() const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0 @@ -357,6 +459,7 @@ export const SettingsPage: React.FC = () => { pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl), jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl), resumeProjects: resumeProjectsOverride, + rxresumeBaseResumeId: normalizeString(data.rxresumeBaseResumeId), ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default), gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default), searchTerms: nullIfSameList(data.searchTerms, searchTerms.default), @@ -502,10 +605,17 @@ export const SettingsPage: React.FC = () => { isLoading={isLoading} isSaving={isSaving} /> - { + setRxResumeBaseResumeIdDraft(value) + setValue("rxresumeBaseResumeId", value, { shouldDirty: true }) + }} + hasRxResumeAccess={hasRxResumeAccess} + profileProjects={effectiveProfileProjects} lockedCount={lockedCount} - maxProjectsTotal={maxProjectsTotal} + maxProjectsTotal={effectiveMaxProjectsTotal} + isProjectsLoading={isFetchingRxResumeProjects} isLoading={isLoading} isSaving={isSaving} /> diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index 30ea1b9..68ff0c8 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -1,16 +1,29 @@ import React, { useEffect, useState } from "react" +import { Controller, useFormContext } from "react-hook-form" import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Input } from "@/components/ui/input" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { Separator } from "@/components/ui/separator" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" +import { clampInt } from "@/lib/utils" +import type { ResumeProjectCatalogItem } from "@shared/types" +import { UpdateSettingsInput } from "@shared/settings-schema" import * as api from "../../../api" type ReactiveResumeSectionProps = { rxResumeBaseResumeIdDraft: string | null setRxResumeBaseResumeIdDraft: (value: string | null) => void - hasRxResumeApiKey: boolean + // True when v4 credentials or v5 API key are configured. + hasRxResumeAccess: boolean + profileProjects: ResumeProjectCatalogItem[] + lockedCount: number + maxProjectsTotal: number + isProjectsLoading: boolean isLoading: boolean isSaving: boolean } @@ -18,16 +31,21 @@ type ReactiveResumeSectionProps = { export const ReactiveResumeSection: React.FC = ({ rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft, - hasRxResumeApiKey, + hasRxResumeAccess, + profileProjects, + lockedCount, + maxProjectsTotal, + isProjectsLoading, isLoading, isSaving, }) => { + const { control, formState: { errors } } = useFormContext() const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]) const [isFetchingResumes, setIsFetchingResumes] = useState(false) const [fetchError, setFetchError] = useState(null) const fetchResumes = async () => { - if (!hasRxResumeApiKey) return + if (!hasRxResumeAccess) return setIsFetchingResumes(true) setFetchError(null) @@ -42,10 +60,10 @@ export const ReactiveResumeSection: React.FC = ({ } useEffect(() => { - if (hasRxResumeApiKey) { + if (hasRxResumeAccess) { fetchResumes() } - }, [hasRxResumeApiKey]) + }, [hasRxResumeAccess]) return ( @@ -54,21 +72,21 @@ export const ReactiveResumeSection: React.FC = ({
- {!hasRxResumeApiKey ? ( + {!hasRxResumeAccess ? ( - API Key Missing + RxResume Access Missing - RXRESUME_API_KEY is not configured in the server environment. Please add it to your .env file. + Configure RxResume credentials in settings (email + password) or set RXRESUME_API_KEY to enable access. ) : ( <> - API Key Configured + RxResume Access Ready - Reactive Resume API integration is active. + Reactive Resume access is active. @@ -112,9 +130,141 @@ export const ReactiveResumeSection: React.FC = ({ )}
- The selected resume will be used as a template for tailoring. A temporary copy will be created during generation and deleted afterwards. + The selected resume will be used as a template for tailoring.
+ + + +
+ {!rxResumeBaseResumeIdDraft ? ( +
+ Choose a PDF to configure resume projects. +
+ ) : ( + <> +
+
+ Max projects to choose +
+ ( + { + if (!field.value) return + const next = Number(event.target.value) + const clamped = clampInt(next, lockedCount, maxProjectsTotal) + field.onChange({ ...field.value, maxProjects: clamped }) + }} + disabled={isLoading || isSaving || isProjectsLoading || !field.value} + /> + )} + /> + {errors.resumeProjects?.maxProjects && ( +

+ {errors.resumeProjects.maxProjects.message} +

+ )} +
+ + ( + + + + Project + Visible in template + Must Include + AI selectable + + + + + {profileProjects.map((project) => { + const locked = Boolean(field.value?.lockedProjectIds.includes(project.id)) + const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id)) + + return ( + + +
+
{project.name || project.id}
+
+ {[project.description, project.date].filter(Boolean).join(" - ")} +
+
+
+ {project.isVisibleInBase ? "Yes" : "No"} + + { + if (!field.value) return + const isChecked = checked === true + const lockedIds = field.value.lockedProjectIds.slice() + const selectableIds = field.value.aiSelectableProjectIds.slice() + + if (isChecked) { + if (!lockedIds.includes(project.id)) lockedIds.push(project.id) + const nextSelectable = selectableIds.filter((id) => id !== project.id) + const minCap = lockedIds.length + field.onChange({ + ...field.value, + lockedProjectIds: lockedIds, + aiSelectableProjectIds: nextSelectable, + maxProjects: Math.max(field.value.maxProjects, minCap), + }) + return + } + + const nextLocked = lockedIds.filter((id) => id !== project.id) + if (!selectableIds.includes(project.id)) selectableIds.push(project.id) + field.onChange({ + ...field.value, + lockedProjectIds: nextLocked, + aiSelectableProjectIds: selectableIds, + maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal), + }) + }} + /> + + + { + if (!field.value) return + const isChecked = checked === true + const selectableIds = field.value.aiSelectableProjectIds.slice() + const nextSelectable = isChecked + ? selectableIds.includes(project.id) + ? selectableIds + : [...selectableIds, project.id] + : selectableIds.filter((id) => id !== project.id) + field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable }) + }} + /> + +
+ ) + })} +
+
+ )} + /> + + )} +
)}
diff --git a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx deleted file mode 100644 index c2f1aef..0000000 --- a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, it, expect } from "vitest" -import { render, screen, fireEvent, waitFor } from "@testing-library/react" -import { useForm, FormProvider } from "react-hook-form" - -import { Accordion } from "@/components/ui/accordion" -import { ResumeProjectsSection } from "./ResumeProjectsSection" -import type { ResumeProjectCatalogItem } from "@shared/types" -import { UpdateSettingsInput } from "@shared/settings-schema" - -const profileProjects: ResumeProjectCatalogItem[] = [ - { - id: "proj-1", - name: "Project One", - description: "Desc 1", - date: "2024", - isVisibleInBase: true, - }, - { - id: "proj-2", - name: "Project Two", - description: "Desc 2", - date: "2023", - isVisibleInBase: false, - }, -] - -const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: UpdateSettingsInput["resumeProjects"] }) => { - const methods = useForm({ - defaultValues: { - resumeProjects: initialDraft - } - }) - const watched = methods.watch() - const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0 - - return ( - - - - - - ) -} - - -describe("ResumeProjectsSection", () => { - it("clamps max projects to the locked count", async () => { - render( - - ) - - const input = screen.getByRole("spinbutton") - fireEvent.change(input, { target: { value: "0" } }) - - await waitFor(() => expect(input).toHaveValue(1)) - }) - - it("locks projects and enforces maxProjects >= locked count", () => { - render( - - ) - - const checkboxes = screen.getAllByRole("checkbox") - const lockedCheckbox = checkboxes[0] - const aiSelectableCheckbox = checkboxes[1] - - fireEvent.click(lockedCheckbox) - - expect(lockedCheckbox).toBeChecked() - expect(aiSelectableCheckbox).toBeChecked() - expect(aiSelectableCheckbox).toBeDisabled() - - const input = screen.getByRole("spinbutton") - expect(input).toHaveValue(1) - }) -}) diff --git a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx deleted file mode 100644 index 92aad8f..0000000 --- a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx +++ /dev/null @@ -1,161 +0,0 @@ -import React from "react" -import { useFormContext, Controller } from "react-hook-form" - -import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Checkbox } from "@/components/ui/checkbox" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import type { ResumeProjectCatalogItem } from "@shared/types" -import { clampInt } from "@/lib/utils" -import { UpdateSettingsInput } from "@shared/settings-schema" - -type ResumeProjectsSectionProps = { - profileProjects: ResumeProjectCatalogItem[] - lockedCount: number - maxProjectsTotal: number - isLoading: boolean - isSaving: boolean -} - -export const ResumeProjectsSection: React.FC = ({ - profileProjects, - lockedCount, - maxProjectsTotal, - isLoading, - isSaving, -}) => { - const { control, formState: { errors } } = useFormContext() - - return ( - - - Resume Projects - - -
-
-
Max projects included
- ( - { - if (!field.value) return - const next = Number(event.target.value) - const clamped = clampInt(next, lockedCount, maxProjectsTotal) - field.onChange({ ...field.value, maxProjects: clamped }) - }} - disabled={isLoading || isSaving || !field.value} - /> - )} - /> - {errors.resumeProjects?.maxProjects &&

{errors.resumeProjects.maxProjects.message}

} -
- AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length} -
-
- - - - ( - - - - Project - Base visible - Locked - AI selectable - - - - {profileProjects.map((project) => { - const locked = Boolean(field.value?.lockedProjectIds.includes(project.id)) - const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id)) - const excluded = !locked && !aiSelectable - - return ( - - -
-
{project.name || project.id}
-
- {[project.description, project.date].filter(Boolean).join(" · ")} - {excluded ? " · Excluded" : ""} -
-
-
- {project.isVisibleInBase ? "Yes" : "No"} - - { - if (!field.value) return - const isChecked = checked === true - const lockedIds = field.value.lockedProjectIds.slice() - const selectableIds = field.value.aiSelectableProjectIds.slice() - - if (isChecked) { - if (!lockedIds.includes(project.id)) lockedIds.push(project.id) - const nextSelectable = selectableIds.filter((id) => id !== project.id) - const minCap = lockedIds.length - field.onChange({ - ...field.value, - lockedProjectIds: lockedIds, - aiSelectableProjectIds: nextSelectable, - maxProjects: Math.max(field.value.maxProjects, minCap), - }) - return - } - - const nextLocked = lockedIds.filter((id) => id !== project.id) - if (!selectableIds.includes(project.id)) selectableIds.push(project.id) - field.onChange({ - ...field.value, - lockedProjectIds: nextLocked, - aiSelectableProjectIds: selectableIds, - maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal), - }) - }} - /> - - - { - if (!field.value) return - const isChecked = checked === true - const selectableIds = field.value.aiSelectableProjectIds.slice() - const nextSelectable = isChecked - ? selectableIds.includes(project.id) - ? selectableIds - : [...selectableIds, project.id] - : selectableIds.filter((id) => id !== project.id) - field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable }) - }} - /> - -
- ) - })} -
-
- )} - /> -
-
-
- ) -} - diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index c299be1..ddfef40 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -11,7 +11,7 @@ import { } from '@server/services/resumeProjects.js'; import { getProfile } from '@server/services/profile.js'; import { getEffectiveSettings } from '@server/services/settings.js'; -import { listResumes, RxResumeCredentialsError } from '@server/services/rxresume-v4.js'; +import { getResume, listResumes, RxResumeCredentialsError } from '@server/services/rxresume-v4.js'; export const settingsRouter = Router(); @@ -58,6 +58,10 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { promises.push(settingsRepo.setSetting('jobCompleteWebhookUrl', input.jobCompleteWebhookUrl ?? null)); } + if ('rxresumeBaseResumeId' in input) { + promises.push(settingsRepo.setSetting('rxresumeBaseResumeId', normalizeEnvInput(input.rxresumeBaseResumeId))); + } + if ('resumeProjects' in input) { const resumeProjects = input.resumeProjects ?? null; @@ -65,13 +69,35 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { promises.push(settingsRepo.setSetting('resumeProjects', null)); } else { promises.push((async () => { - const rawProfile = await getProfile(); + const baseResumeId = 'rxresumeBaseResumeId' in input + ? normalizeEnvInput(input.rxresumeBaseResumeId) + : await settingsRepo.getSetting('rxresumeBaseResumeId'); - if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) { - throw new Error('Invalid resume profile format: expected a non-null object'); + let profile: Record = {}; + + if (baseResumeId) { + try { + const resume = await getResume(baseResumeId); + if (resume.data && typeof resume.data === 'object') { + profile = resume.data as Record; + } + } catch (error) { + if (error instanceof RxResumeCredentialsError) { + throw new Error('RxResume credentials missing while validating resume projects.'); + } + } + } + + if (Object.keys(profile).length === 0) { + const rawProfile = await getProfile(); + + if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) { + throw new Error('Invalid resume profile format: expected a non-null object'); + } + + profile = rawProfile as Record; } - const profile = rawProfile as Record; const { catalog } = extractProjectsFromProfile(profile); const allowed = new Set(catalog.map((p) => p.id)); const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed); @@ -218,3 +244,30 @@ settingsRouter.get('/rx-resumes', async (_req: Request, res: Response) => { res.status(500).json({ success: false, error: message }); } }); + +/** + * GET /api/settings/rx-resumes/:id/projects - Fetch project catalog from RxResume v4 + */ +settingsRouter.get('/rx-resumes/:id/projects', async (req: Request, res: Response) => { + try { + const resumeId = req.params.id; + if (!resumeId) { + res.status(400).json({ success: false, error: 'Resume id is required.' }); + return; + } + + const resume = await getResume(resumeId); + const profile = resume.data ?? {}; + const { catalog } = extractProjectsFromProfile(profile); + + res.json({ success: true, data: { projects: catalog } }); + } catch (error) { + if (error instanceof RxResumeCredentialsError) { + res.status(400).json({ success: false, error: error.message }); + return; + } + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error(`❌ Failed to fetch RxResume projects: ${message}`); + res.status(500).json({ success: false, error: message }); + } +}); diff --git a/orchestrator/src/server/repositories/settings.ts b/orchestrator/src/server/repositories/settings.ts index 6f98678..3c29152 100644 --- a/orchestrator/src/server/repositories/settings.ts +++ b/orchestrator/src/server/repositories/settings.ts @@ -14,6 +14,7 @@ export type SettingKey = 'model' | 'pipelineWebhookUrl' | 'jobCompleteWebhookUrl' | 'resumeProjects' + | 'rxresumeBaseResumeId' | 'ukvisajobsMaxJobs' | 'gradcrackerMaxJobsPerTerm' | 'searchTerms' diff --git a/orchestrator/src/server/services/settings.ts b/orchestrator/src/server/services/settings.ts index 907f6ce..5644a94 100644 --- a/orchestrator/src/server/services/settings.ts +++ b/orchestrator/src/server/services/settings.ts @@ -3,19 +3,38 @@ import * as settingsRepo from '@server/repositories/settings.js'; import { getEnvSettingsData } from './envSettings.js'; import { extractProjectsFromProfile, resolveResumeProjectsSettings } from './resumeProjects.js'; import { getProfile } from './profile.js'; +import { getResume, RxResumeCredentialsError } from './rxresume-v4.js'; /** * Get the effective app settings, combining environment variables and database overrides. */ export async function getEffectiveSettings(): Promise { - // Parallelize slow operations - const [overrides, profile] = await Promise.all([ - settingsRepo.getAllSettings(), - getProfile().catch((error) => { + const overrides = await settingsRepo.getAllSettings(); + + const rxresumeBaseResumeId = overrides.rxresumeBaseResumeId ?? null; + let profile: Record = {}; + + if (rxresumeBaseResumeId) { + try { + const resume = await getResume(rxresumeBaseResumeId); + if (resume.data && typeof resume.data === 'object') { + profile = resume.data as Record; + } + } catch (error) { + if (error instanceof RxResumeCredentialsError) { + console.warn('RxResume credentials missing while loading base resume from settings.'); + } else { + console.warn('Failed to load RxResume base resume for settings:', error); + } + } + } + + if (Object.keys(profile).length === 0) { + profile = await getProfile().catch((error) => { console.warn('Failed to load base resume profile for settings:', error); return {}; - }), - ]); + }); + } const envSettings = await getEnvSettingsData(overrides); @@ -114,6 +133,7 @@ export async function getEffectiveSettings(): Promise { defaultJobCompleteWebhookUrl, overrideJobCompleteWebhookUrl, ...resumeProjectsData, + rxresumeBaseResumeId, ukvisajobsMaxJobs, defaultUkvisajobsMaxJobs, overrideUkvisajobsMaxJobs, diff --git a/orchestrator/src/shared/settings-schema.ts b/orchestrator/src/shared/settings-schema.ts index 4339eb7..5801642 100644 --- a/orchestrator/src/shared/settings-schema.ts +++ b/orchestrator/src/shared/settings-schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const resumeProjectsSchema = z.object({ - maxProjects: z.number().int().min(0).max(100), + maxProjects: z.number().int().min(1).max(100), lockedProjectIds: z.array(z.string().trim().min(1)).max(200), aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200), }); @@ -14,6 +14,7 @@ export const updateSettingsSchema = z.object({ pipelineWebhookUrl: z.string().trim().max(2000).nullable().optional(), jobCompleteWebhookUrl: z.string().trim().max(2000).nullable().optional(), resumeProjects: resumeProjectsSchema.nullable().optional(), + rxresumeBaseResumeId: z.string().trim().max(200).nullable().optional(), ukvisajobsMaxJobs: z.number().int().min(1).max(1000).nullable().optional(), gradcrackerMaxJobsPerTerm: z.number().int().min(1).max(1000).nullable().optional(), searchTerms: z.array(z.string().trim().min(1).max(200)).max(100).nullable().optional(), diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 440d424..049a787 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -363,6 +363,7 @@ export interface AppSettings { resumeProjects: ResumeProjectsSettings; defaultResumeProjects: ResumeProjectsSettings; overrideResumeProjects: ResumeProjectsSettings | null; + rxresumeBaseResumeId: string | null; ukvisajobsMaxJobs: number; defaultUkvisajobsMaxJobs: number; overrideUkvisajobsMaxJobs: number | null; From 9dfb8626490df0b07521cdad20c9fb52cf079aaa Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 11:40:34 +0000 Subject: [PATCH 11/18] ready panel now works with external resume json instead of local --- orchestrator/src/client/api/client.ts | 20 +++- .../src/client/components/ReadyPanel.tsx | 2 +- .../src/client/components/TailoringEditor.tsx | 2 +- .../discovered-panel/TailorMode.tsx | 2 +- .../src/server/api/routes/onboarding.ts | 64 ++++++---- orchestrator/src/server/api/routes/profile.ts | 110 ++++++++---------- .../src/server/api/routes/settings.ts | 31 +---- .../src/server/pipeline/orchestrator.ts | 12 +- orchestrator/src/server/services/pdf.ts | 8 +- .../src/server/services/profile.test.ts | 104 ++++++++++++++--- orchestrator/src/server/services/profile.ts | 58 ++++++--- orchestrator/src/shared/types.ts | 1 - 12 files changed, 250 insertions(+), 164 deletions(-) diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index f1dafcf..c8300b2 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -176,6 +176,19 @@ export async function getProfileProjects(): Promise return fetchApi('/profile/projects'); } +export async function getResumeProjectsCatalog(): Promise { + try { + const settings = await getSettings(); + if (settings.rxresumeBaseResumeId) { + return await getRxResumeProjects(settings.rxresumeBaseResumeId); + } + } catch { + // fall through to profile-based projects + } + + return getProfileProjects(); +} + export async function getProfile(): Promise { return fetchApi('/profile'); } @@ -184,10 +197,9 @@ export async function getProfileStatus(): Promise { return fetchApi('/profile/status'); } -export async function uploadProfile(profile: ResumeProfile): Promise { - return fetchApi('/profile/upload', { +export async function refreshProfile(): Promise { + return fetchApi('/profile/refresh', { method: 'POST', - body: JSON.stringify({ profile }), }); } @@ -205,7 +217,7 @@ export async function validateRxresume(email?: string, password?: string): Promi }); } -export async function validateResumeJson(): Promise { +export async function validateResumeConfig(): Promise { return fetchApi('/onboarding/validate/resume'); } diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index f2a5483..644c986 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -79,7 +79,7 @@ export const ReadyPanel: React.FC = ({ // Load project catalog once useEffect(() => { - api.getProfileProjects().then(setCatalog).catch(console.error); + api.getResumeProjectsCatalog().then(setCatalog).catch(console.error); }, []); // Reset mode when job changes diff --git a/orchestrator/src/client/components/TailoringEditor.tsx b/orchestrator/src/client/components/TailoringEditor.tsx index 6d37fc5..8a4cf09 100644 --- a/orchestrator/src/client/components/TailoringEditor.tsx +++ b/orchestrator/src/client/components/TailoringEditor.tsx @@ -55,7 +55,7 @@ export const TailoringEditor: React.FC = ({ useEffect(() => { // Load project catalog - api.getProfileProjects().then(setCatalog).catch(console.error); + api.getResumeProjectsCatalog().then(setCatalog).catch(console.error); // Set initial selection if (job.selectedProjectIds) { diff --git a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx index e25e07e..eeb0a23 100644 --- a/orchestrator/src/client/components/discovered-panel/TailorMode.tsx +++ b/orchestrator/src/client/components/discovered-panel/TailorMode.tsx @@ -41,7 +41,7 @@ export const TailorMode: React.FC = ({ const [showDescription, setShowDescription] = useState(false); useEffect(() => { - api.getProfileProjects().then(setCatalog).catch(console.error); + api.getResumeProjectsCatalog().then(setCatalog).catch(console.error); }, []); useEffect(() => { diff --git a/orchestrator/src/server/api/routes/onboarding.ts b/orchestrator/src/server/api/routes/onboarding.ts index 723c620..d7608e2 100644 --- a/orchestrator/src/server/api/routes/onboarding.ts +++ b/orchestrator/src/server/api/routes/onboarding.ts @@ -1,9 +1,9 @@ import { Router, Request, Response } from 'express'; -import { readFile, stat } from 'fs/promises'; import { resumeDataSchema } from '@shared/rxresume-schema.js'; -import { DEFAULT_PROFILE_PATH } from '@server/services/profile.js'; import { RxResumeClient } from '@server/services/rxresume-client.js'; +import { getSetting } from '@server/repositories/settings.js'; +import { getResume, RxResumeCredentialsError } from '@server/services/rxresume-v4.js'; export const onboardingRouter = Router(); @@ -55,29 +55,51 @@ async function validateOpenrouter(apiKey?: string | null): Promise { +/** + * Validate that a base resume is configured and accessible via RxResume v4 API. + */ +async function validateResumeConfig(): Promise { try { - const fileInfo = await stat(DEFAULT_PROFILE_PATH); - if (!fileInfo.isFile() || fileInfo.size === 0) { - return { valid: false, message: 'Resume JSON is missing.' }; + // Check if rxresumeBaseResumeId is configured + const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId'); + + if (!rxresumeBaseResumeId) { + return { + valid: false, + message: 'No base resume selected. Please select a resume from your RxResume account in Settings.' + }; } - const raw = await readFile(DEFAULT_PROFILE_PATH, 'utf-8'); - const parsed = JSON.parse(raw); - const result = resumeDataSchema.safeParse(parsed); - if (!result.success) { - const issue = result.error.issues[0]; - const path = issue?.path?.join('.') || ''; - const baseMessage = issue?.message ?? 'Resume JSON does not match the expected schema.'; - const details = path - ? `Field "${path}": ${baseMessage}` - : baseMessage; - return { valid: false, message: details }; - } + // Verify the resume is accessible and valid + try { + const resume = await getResume(rxresumeBaseResumeId); - return { valid: true, message: null }; + if (!resume.data || typeof resume.data !== 'object') { + return { valid: false, message: 'Selected resume is empty or invalid.' }; + } + + // Validate against schema + const result = resumeDataSchema.safeParse(resume.data); + if (!result.success) { + const issue = result.error.issues[0]; + const path = issue?.path?.join('.') || ''; + const baseMessage = issue?.message ?? 'Resume does not match the expected schema.'; + const details = path + ? `Field "${path}": ${baseMessage}` + : baseMessage; + return { valid: false, message: details }; + } + + return { valid: true, message: null }; + } catch (error) { + if (error instanceof RxResumeCredentialsError) { + return { valid: false, message: 'RxResume credentials not configured.' }; + } + const message = error instanceof Error ? error.message : 'Failed to fetch resume from RxResume.'; + return { valid: false, message }; + } } catch (error) { - const message = error instanceof Error ? error.message : 'Unable to read resume JSON.'; + const message = error instanceof Error ? error.message : 'Resume validation failed.'; return { valid: false, message }; } } @@ -119,6 +141,6 @@ onboardingRouter.post('/validate/rxresume', async (req: Request, res: Response) }); onboardingRouter.get('/validate/resume', async (_req: Request, res: Response) => { - const result = await validateResumeJson(); + const result = await validateResumeConfig(); res.json({ success: true, data: result }); }); diff --git a/orchestrator/src/server/api/routes/profile.ts b/orchestrator/src/server/api/routes/profile.ts index c75bd31..e129482 100644 --- a/orchestrator/src/server/api/routes/profile.ts +++ b/orchestrator/src/server/api/routes/profile.ts @@ -1,30 +1,16 @@ import { Router, Request, Response } from 'express'; -import { mkdir, stat, writeFile } from 'fs/promises'; -import { dirname } from 'path'; import { extractProjectsFromProfile } from '../../services/resumeProjects.js'; -import { clearProfileCache, DEFAULT_PROFILE_PATH, getProfile } from '../../services/profile.js'; -import { resumeDataSchema } from '@shared/rxresume-schema.js'; +import { getProfile, clearProfileCache } from '../../services/profile.js'; +import { getSetting } from '../../repositories/settings.js'; +import { getResume, RxResumeCredentialsError } from '../../services/rxresume-v4.js'; export const profileRouter = Router(); -async function profileExists(): Promise { - try { - const fileInfo = await stat(DEFAULT_PROFILE_PATH); - return fileInfo.isFile() && fileInfo.size > 0; - } catch { - return false; - } -} - /** * GET /api/profile/projects - Get all projects available in the base resume */ profileRouter.get('/projects', async (req: Request, res: Response) => { try { - if (!(await profileExists())) { - res.json({ success: true, data: [] }); - return; - } const profile = await getProfile(); const { catalog } = extractProjectsFromProfile(profile); res.json({ success: true, data: catalog }); @@ -39,10 +25,6 @@ profileRouter.get('/projects', async (req: Request, res: Response) => { */ profileRouter.get('/', async (req: Request, res: Response) => { try { - if (!(await profileExists())) { - res.json({ success: true, data: null }); - return; - } const profile = await getProfile(); res.json({ success: true, data: profile }); } catch (error) { @@ -52,13 +34,51 @@ profileRouter.get('/', async (req: Request, res: Response) => { }); /** - * GET /api/profile/status - Check if base resume exists + * GET /api/profile/status - Check if base resume is configured and accessible */ profileRouter.get('/status', async (_req: Request, res: Response) => { try { - const fileInfo = await stat(DEFAULT_PROFILE_PATH); - const exists = fileInfo.isFile() && fileInfo.size > 0; - res.json({ success: true, data: { exists, error: exists ? null : 'Resume file is empty' } }); + const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId'); + + if (!rxresumeBaseResumeId) { + res.json({ + success: true, + data: { + exists: false, + error: 'No base resume selected. Please select a resume from your RxResume account in Settings.' + } + }); + return; + } + + // Verify the resume is accessible + try { + const resume = await getResume(rxresumeBaseResumeId); + if (!resume.data || typeof resume.data !== 'object') { + res.json({ + success: true, + data: { + exists: false, + error: 'Selected resume is empty or invalid.' + } + }); + return; + } + + res.json({ success: true, data: { exists: true, error: null } }); + } catch (error) { + if (error instanceof RxResumeCredentialsError) { + res.json({ + success: true, + data: { + exists: false, + error: 'RxResume credentials not configured.' + } + }); + return; + } + throw error; + } } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error'; res.json({ success: true, data: { exists: false, error: message } }); @@ -66,43 +86,15 @@ profileRouter.get('/status', async (_req: Request, res: Response) => { }); /** - * POST /api/profile/upload - Upload base resume JSON + * POST /api/profile/refresh - Clear profile cache and refetch from RxResume v4 API */ -profileRouter.post('/upload', async (req: Request, res: Response) => { +profileRouter.post('/refresh', async (_req: Request, res: Response) => { try { - const profile = (req.body && typeof req.body === 'object' ? (req.body as Record).profile : null) as unknown; - - if (!profile || typeof profile !== 'object' || Array.isArray(profile)) { - throw new Error('Invalid profile payload. Expected a JSON object.'); - } - - const parsed = resumeDataSchema.safeParse(profile); - if (!parsed.success) { - const issue = parsed.error.issues[0]; - const path = issue?.path?.join('.') || ''; - const baseMessage = issue?.message ?? 'Resume JSON does not match the RxResume schema.'; - const details = path ? `Field "${path}": ${baseMessage}` : baseMessage; - throw new Error(`Invalid resume JSON: ${details}`); - } - - const existing = await stat(DEFAULT_PROFILE_PATH).catch(() => null); - if (existing && existing.isDirectory()) { - throw new Error('Resume path is a directory. Remove it and upload again.'); - } - - await mkdir(dirname(DEFAULT_PROFILE_PATH), { recursive: true }); - await writeFile(DEFAULT_PROFILE_PATH, JSON.stringify(parsed.data, null, 2), 'utf-8'); clearProfileCache(); - - res.json({ success: true, data: { exists: true, error: null } }); + const profile = await getProfile(true); + res.json({ success: true, data: profile }); } catch (error) { - let message = error instanceof Error ? error.message : 'Unknown error'; - if (error && typeof error === 'object' && 'code' in error) { - const code = (error as { code?: string }).code; - if (code === 'EROFS') { - message = 'Resume path is read-only. Remove the bind mount and restart the container.'; - } - } - res.status(400).json({ success: false, error: message }); + const message = error instanceof Error ? error.message : 'Unknown error'; + res.status(500).json({ success: false, error: message }); } }); diff --git a/orchestrator/src/server/api/routes/settings.ts b/orchestrator/src/server/api/routes/settings.ts index ddfef40..04956f0 100644 --- a/orchestrator/src/server/api/routes/settings.ts +++ b/orchestrator/src/server/api/routes/settings.ts @@ -69,35 +69,8 @@ settingsRouter.patch('/', async (req: Request, res: Response) => { promises.push(settingsRepo.setSetting('resumeProjects', null)); } else { promises.push((async () => { - const baseResumeId = 'rxresumeBaseResumeId' in input - ? normalizeEnvInput(input.rxresumeBaseResumeId) - : await settingsRepo.getSetting('rxresumeBaseResumeId'); - - let profile: Record = {}; - - if (baseResumeId) { - try { - const resume = await getResume(baseResumeId); - if (resume.data && typeof resume.data === 'object') { - profile = resume.data as Record; - } - } catch (error) { - if (error instanceof RxResumeCredentialsError) { - throw new Error('RxResume credentials missing while validating resume projects.'); - } - } - } - - if (Object.keys(profile).length === 0) { - const rawProfile = await getProfile(); - - if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) { - throw new Error('Invalid resume profile format: expected a non-null object'); - } - - profile = rawProfile as Record; - } - + // getProfile() will fetch from RxResume v4 API using rxresumeBaseResumeId + const profile = await getProfile(); const { catalog } = extractProjectsFromProfile(profile); const allowed = new Set(catalog.map((p) => p.id)); const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed); diff --git a/orchestrator/src/server/pipeline/orchestrator.ts b/orchestrator/src/server/pipeline/orchestrator.ts index 4715bed..4bcac39 100644 --- a/orchestrator/src/server/pipeline/orchestrator.ts +++ b/orchestrator/src/server/pipeline/orchestrator.ts @@ -14,7 +14,7 @@ import { runUkVisaJobs } from '../services/ukvisajobs.js'; import { scoreJobSuitability } from '../services/scorer.js'; import { generateTailoring } from '../services/summary.js'; import { generatePdf } from '../services/pdf.js'; -import { DEFAULT_PROFILE_PATH, getProfile } from '../services/profile.js'; +import { getProfile } from '../services/profile.js'; import { getSetting } from '../repositories/settings.js'; import { pickProjectIdsForJob } from '../services/projectSelection.js'; import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../services/resumeProjects.js'; @@ -30,7 +30,6 @@ const DEFAULT_CONFIG: PipelineConfig = { topN: 10, minSuitabilityScore: 50, sources: ['gradcracker', 'indeed', 'linkedin', 'ukvisajobs'], - profilePath: DEFAULT_PROFILE_PATH, outputDir: join(getDataDir(), 'pdfs'), enableCrawling: true, enableScoring: true, @@ -108,7 +107,7 @@ export async function runPipeline(config: Partial = {}): Promise try { // Step 1: Load profile console.log('\n📋 Loading profile...'); - const profile = await getProfile(mergedConfig.profilePath).catch((error) => { + const profile = await getProfile().catch((error) => { console.warn('⚠️ Failed to load profile for scoring, using empty profile:', error); return {} as Record; }); @@ -348,7 +347,7 @@ export async function runPipeline(config: Partial = {}): Promise // Process job (Generate Summary + PDF) // We catch errors here to ensure one failure doesn't stop the whole batch - const result = await processJob(job.id, { profilePath: mergedConfig.profilePath }); + const result = await processJob(job.id, { force: false }); if (result.success) { processedCount++; @@ -417,7 +416,6 @@ export async function runPipeline(config: Partial = {}): Promise export type ProcessJobOptions = { force?: boolean; - profilePath?: string; }; /** @@ -436,7 +434,7 @@ export async function summarizeJob( const job = await jobsRepo.getJobById(jobId); if (!job) return { success: false, error: 'Job not found' }; - const profile = await getProfile(options?.profilePath); + const profile = await getProfile(); // 1. Generate Summary & Tailoring let tailoredSummary = job.tailoredSummary; @@ -520,7 +518,7 @@ export async function generateFinalPdf( skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : [] }, job.jobDescription || '', - options?.profilePath || DEFAULT_PROFILE_PATH, + undefined, // deprecated baseResumePath parameter job.selectedProjectIds ); diff --git a/orchestrator/src/server/services/pdf.ts b/orchestrator/src/server/services/pdf.ts index 29c7796..55b4378 100644 --- a/orchestrator/src/server/services/pdf.ts +++ b/orchestrator/src/server/services/pdf.ts @@ -93,7 +93,7 @@ export async function generatePdf( jobId: string, tailoredContent: TailoredPdfContent, jobDescription: string, - baseResumePath?: string, + _baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API selectedProjectIds?: string | null ): Promise { console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`); @@ -108,10 +108,8 @@ export async function generatePdf( const { email, password, baseUrl } = await getCredentials(); const client = new RxResumeClient(baseUrl); - // Read base resume - const baseResume = baseResumePath - ? JSON.parse(await import('fs/promises').then(fs => fs.readFile(baseResumePath, 'utf-8'))) - : JSON.parse(JSON.stringify(await getProfile())); + // Read base resume from profile (fetches from v4 API if configured) + const baseResume = JSON.parse(JSON.stringify(await getProfile())); // Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords) // This fixes issues where the base JSON uses a shorthand format (missing required fields) diff --git a/orchestrator/src/server/services/profile.test.ts b/orchestrator/src/server/services/profile.test.ts index 7d17a42..1cf02d6 100644 --- a/orchestrator/src/server/services/profile.test.ts +++ b/orchestrator/src/server/services/profile.test.ts @@ -1,32 +1,100 @@ - import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { readFile } from 'fs/promises'; -import { getProfile } from './profile.js'; +import { getProfile, clearProfileCache } from './profile.js'; -vi.mock('fs/promises', async () => { - const fn = vi.fn(); - return { - readFile: fn, - default: { - readFile: fn +// Mock the dependencies +vi.mock('../repositories/settings.js', () => ({ + getSetting: vi.fn(), +})); + +vi.mock('./rxresume-v4.js', () => ({ + getResume: vi.fn(), + RxResumeCredentialsError: class RxResumeCredentialsError extends Error { + constructor() { + super('RxResume credentials not configured.'); + this.name = 'RxResumeCredentialsError'; } - }; -}); + }, +})); -describe('getProfile failure', () => { +import { getSetting } from '../repositories/settings.js'; +import { getResume, RxResumeCredentialsError } from './rxresume-v4.js'; + +describe('getProfile', () => { beforeEach(() => { vi.resetAllMocks(); + clearProfileCache(); }); - it('should throw an error if the profile file does not exist', async () => { - vi.mocked(readFile).mockRejectedValue(new Error('ENOENT: no such file or directory')); + it('should throw an error if rxresumeBaseResumeId is not configured', async () => { + vi.mocked(getSetting).mockResolvedValue(null); - await expect(getProfile('/non/existent/path.json', true)).rejects.toThrow('ENOENT: no such file or directory'); + await expect(getProfile()).rejects.toThrow( + 'Base resume not configured. Please select a base resume from your RxResume account in Settings.' + ); }); - it('should throw an error if the profile file is invalid JSON', async () => { - vi.mocked(readFile).mockResolvedValue('invalid json'); + it('should fetch profile from RxResume v4 API when configured', async () => { + const mockResumeData = { basics: { name: 'Test User' } }; + vi.mocked(getSetting).mockResolvedValue('test-resume-id'); + vi.mocked(getResume).mockResolvedValue({ + id: 'test-resume-id', + data: mockResumeData + } as any); - await expect(getProfile('/invalid/json.json', true)).rejects.toThrow(); + const profile = await getProfile(); + + expect(getSetting).toHaveBeenCalledWith('rxresumeBaseResumeId'); + expect(getResume).toHaveBeenCalledWith('test-resume-id'); + expect(profile).toEqual(mockResumeData); + }); + + it('should cache the profile and not refetch on subsequent calls', async () => { + const mockResumeData = { basics: { name: 'Test User' } }; + vi.mocked(getSetting).mockResolvedValue('test-resume-id'); + vi.mocked(getResume).mockResolvedValue({ + id: 'test-resume-id', + data: mockResumeData + } as any); + + await getProfile(); + await getProfile(); + + // getSetting is called each time to check resumeId + expect(getSetting).toHaveBeenCalledTimes(2); + // But getResume should only be called once due to caching + expect(getResume).toHaveBeenCalledTimes(1); + }); + + it('should refetch when forceRefresh is true', async () => { + const mockResumeData = { basics: { name: 'Test User' } }; + vi.mocked(getSetting).mockResolvedValue('test-resume-id'); + vi.mocked(getResume).mockResolvedValue({ + id: 'test-resume-id', + data: mockResumeData + } as any); + + await getProfile(); + await getProfile(true); + + expect(getResume).toHaveBeenCalledTimes(2); + }); + + it('should throw user-friendly error on credential issues', async () => { + vi.mocked(getSetting).mockResolvedValue('test-resume-id'); + vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError()); + + await expect(getProfile()).rejects.toThrow( + 'RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.' + ); + }); + + it('should throw error if resume data is empty', async () => { + vi.mocked(getSetting).mockResolvedValue('test-resume-id'); + vi.mocked(getResume).mockResolvedValue({ + id: 'test-resume-id', + data: null + } as any); + + await expect(getProfile()).rejects.toThrow('Resume data is empty or invalid'); }); }); diff --git a/orchestrator/src/server/services/profile.ts b/orchestrator/src/server/services/profile.ts index dd935dd..6470f4c 100644 --- a/orchestrator/src/server/services/profile.ts +++ b/orchestrator/src/server/services/profile.ts @@ -1,33 +1,56 @@ -import { readFile } from 'fs/promises'; -import { join } from 'path'; +/** + * Profile service - fetches resume data from RxResume v4 API. + * + * The rxresumeBaseResumeId setting is REQUIRED for the app to function. + * There is no local file fallback. + */ -import { getDataDir } from '../config/dataDir.js'; - -export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(getDataDir(), 'resume.json'); +import { getSetting } from '../repositories/settings.js'; +import { getResume, RxResumeCredentialsError } from './rxresume-v4.js'; let cachedProfile: any = null; -let cachedProfilePath: string | null = null; +let cachedResumeId: string | null = null; /** - * Get the base resume profile from resume.json. - * Caches the result since it doesn't change often. - * @param profilePath Optional absolute path to profile JSON. Defaults to base.json. - * @param forceRefresh Force reload from disk. + * Get the base resume profile from RxResume v4 API. + * + * Requires rxresumeBaseResumeId to be configured in settings. + * Results are cached until clearProfileCache() is called. + * + * @param forceRefresh Force reload from API. + * @throws Error if rxresumeBaseResumeId is not configured or API call fails. */ -export async function getProfile(profilePath?: string, forceRefresh = false): Promise { - const targetPath = profilePath || DEFAULT_PROFILE_PATH; +export async function getProfile(forceRefresh = false): Promise { + const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId'); - if (cachedProfile && cachedProfilePath === targetPath && !forceRefresh) { + if (!rxresumeBaseResumeId) { + throw new Error( + 'Base resume not configured. Please select a base resume from your RxResume account in Settings.' + ); + } + + // Return cached profile if valid + if (cachedProfile && cachedResumeId === rxresumeBaseResumeId && !forceRefresh) { return cachedProfile; } try { - const content = await readFile(targetPath, 'utf-8'); - cachedProfile = JSON.parse(content); - cachedProfilePath = targetPath; + console.log(`📋 Fetching profile from RxResume v4 API (resume: ${rxresumeBaseResumeId})...`); + const resume = await getResume(rxresumeBaseResumeId); + + if (!resume.data || typeof resume.data !== 'object') { + throw new Error('Resume data is empty or invalid'); + } + + cachedProfile = resume.data; + cachedResumeId = rxresumeBaseResumeId; + console.log(`✅ Profile loaded from RxResume v4 API`); return cachedProfile; } catch (error) { - console.error(`❌ Failed to load profile from ${targetPath}:`, error); + if (error instanceof RxResumeCredentialsError) { + throw new Error('RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.'); + } + console.error(`❌ Failed to load profile from RxResume v4 API:`, error); throw error; } } @@ -45,4 +68,5 @@ export async function getPersonName(): Promise { */ export function clearProfileCache(): void { cachedProfile = null; + cachedResumeId = null; } diff --git a/orchestrator/src/shared/types.ts b/orchestrator/src/shared/types.ts index 049a787..a9f4d22 100644 --- a/orchestrator/src/shared/types.ts +++ b/orchestrator/src/shared/types.ts @@ -174,7 +174,6 @@ export interface PipelineConfig { topN: number; // Number of top jobs to process minSuitabilityScore: number; // Minimum score to auto-process sources: JobSource[]; // Job sources to crawl - profilePath: string; // Path to profile JSON outputDir: string; // Directory for generated PDFs enableCrawling?: boolean; enableScoring?: boolean; From a268bfdd59f0b97498b351c8ad303c433b5fd6d2 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 11:59:02 +0000 Subject: [PATCH 12/18] onboarding UI ensures that we have a resume base id when we're in the app --- orchestrator/src/client/api/client.ts | 1 + .../src/client/components/OnboardingGate.tsx | 112 ++++++++++++++++-- .../components/BaseResumeSelection.tsx | 107 +++++++++++++++++ .../components/ReactiveResumeSection.tsx | 79 ++---------- 4 files changed, 218 insertions(+), 81 deletions(-) create mode 100644 orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index c8300b2..182e772 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -247,6 +247,7 @@ export async function updateSettings(update: { ukvisajobsEmail?: string | null ukvisajobsPassword?: string | null webhookSecret?: string | null + rxresumeBaseResumeId?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 46e0474..8d5e740 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -12,6 +12,7 @@ import * as api from "@client/api" import { useSettings } from "@client/hooks/useSettings" import { SettingsInput } from "@client/pages/settings/components/SettingsInput" import { formatSecretHint } from "@client/pages/settings/utils" +import { BaseResumeSelection } from "@client/pages/settings/components/BaseResumeSelection" import type { ValidationResult } from "@shared/types" type ValidationState = ValidationResult & { checked: boolean } @@ -21,6 +22,7 @@ export const OnboardingGate: React.FC = () => { const [isSavingEnv, setIsSavingEnv] = useState(false) const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false) const [isValidatingRxresume, setIsValidatingRxresume] = useState(false) + const [isValidatingBaseResume, setIsValidatingBaseResume] = useState(false) const [openrouterValidation, setOpenrouterValidation] = useState({ valid: false, message: null, @@ -31,11 +33,17 @@ export const OnboardingGate: React.FC = () => { message: null, checked: false, }) + const [baseResumeValidation, setBaseResumeValidation] = useState({ + valid: false, + message: null, + checked: false, + }) const [currentStep, setCurrentStep] = useState(null) const [openrouterApiKey, setOpenrouterApiKey] = useState("") const [rxresumeEmail, setRxresumeEmail] = useState("") const [rxresumePassword, setRxresumePassword] = useState("") + const [rxresumeBaseResumeId, setRxresumeBaseResumeId] = useState(null) const validateOpenrouter = useCallback(async (apiKey?: string) => { setIsValidatingOpenrouter(true) @@ -69,11 +77,27 @@ export const OnboardingGate: React.FC = () => { } }, []) + const validateBaseResume = useCallback(async () => { + setIsValidatingBaseResume(true) + try { + const result = await api.validateResumeConfig() + setBaseResumeValidation({ ...result, checked: true }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : "Base resume validation failed" + const result = { valid: false, message } + setBaseResumeValidation({ ...result, checked: true }) + return result + } finally { + setIsValidatingBaseResume(false) + } + }, []) + const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint) const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()) const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint) const shouldOpen = Boolean(settings && !settingsLoading) - && !(openrouterValidation.valid && rxresumeValidation.valid) + && !(openrouterValidation.valid && rxresumeValidation.valid && baseResumeValidation.valid) const openrouterCurrent = settings?.openrouterApiKeyHint ? formatSecretHint(settings.openrouterApiKeyHint) @@ -85,6 +109,12 @@ export const OnboardingGate: React.FC = () => { ? formatSecretHint(settings.rxresumePasswordHint) : undefined + useEffect(() => { + if (settings) { + setRxresumeBaseResumeId(settings.rxresumeBaseResumeId || null) + } + }, [settings]) + const steps = useMemo( () => [ { @@ -92,15 +122,24 @@ export const OnboardingGate: React.FC = () => { label: "Connect AI", subtitle: "OpenRouter key", complete: openrouterValidation.valid, + disabled: false, }, { id: "rxresume", - label: "PDF Export", - subtitle: "RxResume login", + label: "Connect Reactive Resume", + subtitle: "Reactive Resume login", complete: rxresumeValidation.valid, + disabled: false, + }, + { + id: "baseresume", + label: "Select Template Resume", + subtitle: "Template selection", + complete: baseResumeValidation.valid, + disabled: !rxresumeValidation.valid, }, ], - [openrouterValidation.valid, rxresumeValidation.valid] + [openrouterValidation.valid, rxresumeValidation.valid, baseResumeValidation.valid] ) const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id @@ -117,6 +156,7 @@ export const OnboardingGate: React.FC = () => { const results = await Promise.allSettled([ validateOpenrouter(), validateRxresume(), + validateBaseResume(), ]) const failed = results.find((result) => result.status === "rejected") @@ -219,17 +259,46 @@ export const OnboardingGate: React.FC = () => { } } + const handleSaveBaseResume = async (): Promise => { + if (!rxresumeBaseResumeId) { + toast.info("Select a base resume to continue") + return false + } + + try { + setIsSavingEnv(true) + await api.updateSettings({ rxresumeBaseResumeId: rxresumeBaseResumeId }) + const validation = await validateBaseResume() + if (!validation.valid) { + toast.error(validation.message || "Base resume validation failed") + return false + } + + await refreshSettings() + toast.success("Base resume set") + return true + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save base resume" + toast.error(message) + return false + } finally { + setIsSavingEnv(false) + } + } + const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0 const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0 const completedSteps = steps.filter((step) => step.complete).length const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0 - const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume + const isBusy = isSavingEnv || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingBaseResume const canGoBack = stepIndex > 0 const primaryLabel = currentStep === "openrouter" ? (openrouterValidation.valid ? "Revalidate" : "Validate") : currentStep === "rxresume" ? (rxresumeValidation.valid ? "Revalidate" : "Validate") - : "Validate" + : currentStep === "baseresume" + ? (baseResumeValidation.valid ? "Revalidate" : "Validate") + : "Validate" const handlePrimaryAction = async () => { if (!currentStep) return @@ -241,6 +310,10 @@ export const OnboardingGate: React.FC = () => { await handleSaveRxresume() return } + if (currentStep === "baseresume") { + await handleSaveBaseResume() + return + } } const handleBack = () => { @@ -265,7 +338,7 @@ export const OnboardingGate: React.FC = () => { - + {steps.map((step, index) => { const isActive = step.id === currentStep const isComplete = step.complete @@ -273,13 +346,17 @@ export const OnboardingGate: React.FC = () => { return ( [data-slot=field]]:border-0 [&>[data-slot=field]]:p-0 [&>[data-slot=field]]:rounded-none", + step.disabled && "opacity-50 cursor-not-allowed" + )} > @@ -356,6 +433,21 @@ export const OnboardingGate: React.FC = () => {
+ +
+

Select your template resume

+

Choose the resume you want to use as a template. + The selected resume will be used as a template for tailoring. +

+
+ +
+
diff --git a/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx b/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx new file mode 100644 index 0000000..95c6b8b --- /dev/null +++ b/orchestrator/src/client/pages/settings/components/BaseResumeSelection.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from "react" +import { RefreshCw } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import * as api from "@client/api" + +type BaseResumeSelectionProps = { + value: string | null + onValueChange: (value: string | null) => void + hasRxResumeAccess: boolean + disabled?: boolean + isLoading?: boolean +} + +export const BaseResumeSelection: React.FC = ({ + value, + onValueChange, + hasRxResumeAccess, + disabled = false, + isLoading = false, +}) => { + const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]) + const [isFetchingResumes, setIsFetchingResumes] = useState(false) + const [fetchError, setFetchError] = useState(null) + + const fetchResumes = async () => { + if (!hasRxResumeAccess) return + + setIsFetchingResumes(true) + setFetchError(null) + try { + const data = await api.getRxResumes() + setResumes(data) + + // Preselect if only one option is available and no value is currently set + if (data.length === 1 && !value) { + onValueChange(data[0].id) + } + } catch (error) { + setFetchError(error instanceof Error ? error.message : "Failed to fetch resumes") + } finally { + setIsFetchingResumes(false) + } + } + + useEffect(() => { + if (hasRxResumeAccess) { + fetchResumes() + } + }, [hasRxResumeAccess]) + + return ( +
+
+
Template Resume
+ +
+ + + + {resumes.length === 0 && !isFetchingResumes && !fetchError && ( +
+ No resumes found in your account. Please create a resume on the{" "} + + Reactive Resume website + {" "} + first. +
+ )} + + {fetchError && ( +
+ {fetchError} +
+ )} +
+ ) +} diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index 68ff0c8..eded825 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -1,19 +1,17 @@ import React, { useEffect, useState } from "react" import { Controller, useFormContext } from "react-hook-form" -import { AlertCircle, CheckCircle2, RefreshCw } from "lucide-react" +import { AlertCircle, CheckCircle2 } from "lucide-react" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" -import { Button } from "@/components/ui/button" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Separator } from "@/components/ui/separator" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { clampInt } from "@/lib/utils" import type { ResumeProjectCatalogItem } from "@shared/types" import { UpdateSettingsInput } from "@shared/settings-schema" -import * as api from "../../../api" +import { BaseResumeSelection } from "./BaseResumeSelection" type ReactiveResumeSectionProps = { rxResumeBaseResumeIdDraft: string | null @@ -40,30 +38,6 @@ export const ReactiveResumeSection: React.FC = ({ isSaving, }) => { const { control, formState: { errors } } = useFormContext() - const [resumes, setResumes] = useState<{ id: string; name: string }[]>([]) - const [isFetchingResumes, setIsFetchingResumes] = useState(false) - const [fetchError, setFetchError] = useState(null) - - const fetchResumes = async () => { - if (!hasRxResumeAccess) 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 (hasRxResumeAccess) { - fetchResumes() - } - }, [hasRxResumeAccess]) return ( @@ -90,49 +64,12 @@ export const ReactiveResumeSection: React.FC = ({ -
-
-
Base Resume
- -
- - - - {fetchError && ( -
- {fetchError} -
- )} - -
- The selected resume will be used as a template for tailoring. -
-
+ From 3f37029dfd90665da08018ee4c36a00ebcff5206 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 12:08:17 +0000 Subject: [PATCH 13/18] address comments --- orchestrator/src/client/api/client.ts | 19 ++++++++++++++++--- .../src/client/components/OnboardingGate.tsx | 6 +++--- .../src/client/pages/SettingsPage.tsx | 10 +++++++--- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/orchestrator/src/client/api/client.ts b/orchestrator/src/client/api/client.ts index 182e772..46b8cbd 100644 --- a/orchestrator/src/client/api/client.ts +++ b/orchestrator/src/client/api/client.ts @@ -168,8 +168,20 @@ export async function importManualJob(input: { } // Settings & Profile API +let settingsPromise: Promise | null = null; + export async function getSettings(): Promise { - return fetchApi('/settings'); + if (settingsPromise) return settingsPromise; + + settingsPromise = fetchApi('/settings').finally(() => { + // Clear the promise after a short delay to allow subsequent fresh fetches + // but coalesce simultaneous requests. + setTimeout(() => { + settingsPromise = null; + }, 100); + }); + + return settingsPromise; } export async function getProfileProjects(): Promise { @@ -260,9 +272,10 @@ export async function getRxResumes(): Promise<{ id: string; name: string }[]> { return data.resumes; } -export async function getRxResumeProjects(resumeId: string): Promise { +export async function getRxResumeProjects(resumeId: string, signal?: AbortSignal): Promise { const data = await fetchApi<{ projects: ResumeProjectCatalogItem[] }>( - `/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects` + `/settings/rx-resumes/${encodeURIComponent(resumeId)}/projects`, + { signal } ); return data.projects; } diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx index 8d5e740..4e5b528 100644 --- a/orchestrator/src/client/components/OnboardingGate.tsx +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -165,13 +165,13 @@ export const OnboardingGate: React.FC = () => { const message = reason instanceof Error ? reason.message : "Validation checks failed" toast.error(message) } - }, [settings, validateOpenrouter, validateRxresume]) + }, [settings, validateOpenrouter, validateRxresume, validateBaseResume]) useEffect(() => { if (!settings || settingsLoading) return - if (openrouterValidation.checked || rxresumeValidation.checked) return + if (openrouterValidation.checked || rxresumeValidation.checked || baseResumeValidation.checked) return void runAllValidations() - }, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, runAllValidations]) + }, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, baseResumeValidation.checked, runAllValidations]) const handleRefresh = async () => { const results = await Promise.allSettled([refreshSettings(), runAllValidations()]) diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index a286d4a..f697a04 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -156,7 +156,7 @@ const normalizeResumeProjectsForCatalog = ( const lockedProjectIds = base.lockedProjectIds.filter((id) => allowed.has(id)) const lockedSet = new Set(lockedProjectIds) - const aiSelectableProjectIds = (base.aiSelectableProjectIds.length + const aiSelectableProjectIds = (current ? base.aiSelectableProjectIds : catalog.map((project) => project.id) ) @@ -320,21 +320,24 @@ export const SettingsPage: React.FC = () => { useEffect(() => { let isMounted = true + const controller = new AbortController() if (!rxResumeBaseResumeIdDraft) { setRxResumeProjectsOverride(null) return () => { isMounted = false + controller.abort() } } if (!hasRxResumeAccess) return () => { isMounted = false + controller.abort() } setIsFetchingRxResumeProjects(true) api - .getRxResumeProjects(rxResumeBaseResumeIdDraft) + .getRxResumeProjects(rxResumeBaseResumeIdDraft, controller.signal) .then((projects) => { if (!isMounted) return setRxResumeProjectsOverride(projects) @@ -347,7 +350,7 @@ export const SettingsPage: React.FC = () => { } }) .catch((error) => { - if (!isMounted) return + if (!isMounted || error.name === 'AbortError') return const message = error instanceof Error ? error.message : "Failed to load RxResume projects" toast.error(message) setRxResumeProjectsOverride(null) @@ -359,6 +362,7 @@ export const SettingsPage: React.FC = () => { return () => { isMounted = false + controller.abort() } }, [rxResumeBaseResumeIdDraft, hasRxResumeAccess, getValues, setValue]) From fa13709738d0a8872f775682b21a6c9574121249 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 12:25:00 +0000 Subject: [PATCH 14/18] tests --- .../src/server/api/routes/onboarding.test.ts | 60 +--- .../src/server/api/routes/profile.test.ts | 315 +++++++----------- .../services/pdf-skills-validation.test.ts | 17 +- .../src/server/services/pdf-tailoring.test.ts | 5 + .../server/services/rxresume-client.test.ts | 5 + .../src/server/tailoring-flow.test.ts | 4 +- 6 files changed, 147 insertions(+), 259 deletions(-) diff --git a/orchestrator/src/server/api/routes/onboarding.test.ts b/orchestrator/src/server/api/routes/onboarding.test.ts index 976e3b6..5187b73 100644 --- a/orchestrator/src/server/api/routes/onboarding.test.ts +++ b/orchestrator/src/server/api/routes/onboarding.test.ts @@ -1,7 +1,5 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { Server } from 'http'; -import { writeFile } from 'fs/promises'; -import { join } from 'path'; import { startServer, stopServer } from './test-utils.js'; import { RxResumeClient } from '@server/services/rxresume-client.js'; @@ -154,67 +152,19 @@ describe.sequential('Onboarding API routes', () => { }); describe('GET /api/onboarding/validate/resume', () => { - it('returns invalid when no resume file exists', async () => { + it('returns invalid when rxresumeBaseResumeId is not configured', async () => { const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`); const body = await res.json(); expect(res.ok).toBe(true); expect(body.success).toBe(true); expect(body.data.valid).toBe(false); - expect(body.data.message).toBeTruthy(); + expect(body.data.message).toContain('No base resume selected'); }); - it('returns invalid when resume file is empty', async () => { - // Create an empty resume file - const resumePath = join(tempDir, 'resume.json'); - await writeFile(resumePath, ''); - - const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`); - const body = await res.json(); - - expect(res.ok).toBe(true); - expect(body.data.valid).toBe(false); - }); - - it('returns invalid when resume file is invalid JSON', async () => { - const resumePath = join(tempDir, 'resume.json'); - await writeFile(resumePath, 'not valid json {{{'); - - const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`); - const body = await res.json(); - - expect(res.ok).toBe(true); - expect(body.data.valid).toBe(false); - expect(body.data.message).toBeTruthy(); - }); - - it('returns invalid with field path when resume does not match schema', async () => { - const resumePath = join(tempDir, 'resume.json'); - // Valid JSON but missing required fields - await writeFile(resumePath, JSON.stringify({ foo: 'bar' })); - - const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`); - const body = await res.json(); - - expect(res.ok).toBe(true); - expect(body.data.valid).toBe(false); - // Should include field path in error message - expect(body.data.message).toBeTruthy(); - }); - - it('returns valid when resume file is valid and matches schema', async () => { - const resumePath = join(tempDir, 'resume.json'); - const validResume = createMinimalValidResume(); - await writeFile(resumePath, JSON.stringify(validResume)); - - const res = await fetch(`${baseUrl}/api/onboarding/validate/resume`); - const body = await res.json(); - - expect(res.ok).toBe(true); - expect(body.success).toBe(true); - expect(body.data.valid).toBe(true); - expect(body.data.message).toBeNull(); - }); + // Note: Further validation tests require mocking getSetting and getResume + // which is complex in integration tests. The validation logic is covered + // by unit tests in profile.test.ts and the service tests. }); }); diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index db71386..ed1b5c5 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -1,9 +1,38 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import type { Server } from 'http'; -import { writeFile, stat } from 'fs/promises'; -import { join } from 'path'; import { startServer, stopServer } from './test-utils.js'; +// Mock the rxresume-v4 service +vi.mock('../../services/rxresume-v4.js', () => ({ + getResume: vi.fn(), + listResumes: vi.fn(), + RxResumeCredentialsError: class RxResumeCredentialsError extends Error { + constructor() { + super('RxResume credentials not configured.'); + this.name = 'RxResumeCredentialsError'; + } + }, +})); + +// Mock the profile service +vi.mock('../../services/profile.js', () => ({ + getProfile: vi.fn(), + clearProfileCache: vi.fn(), +})); + +// Mock the settings repository +vi.mock('../../repositories/settings.js', async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + getSetting: vi.fn(), + }; +}); + +import { getResume, RxResumeCredentialsError } from '../../services/rxresume-v4.js'; +import { getProfile, clearProfileCache } from '../../services/profile.js'; +import { getSetting } from '../../repositories/settings.js'; + describe.sequential('Profile API routes', () => { let server: Server; let baseUrl: string; @@ -11,6 +40,7 @@ describe.sequential('Profile API routes', () => { let tempDir: string; beforeEach(async () => { + vi.clearAllMocks(); ({ server, baseUrl, closeDb, tempDir } = await startServer()); }); @@ -18,73 +48,88 @@ describe.sequential('Profile API routes', () => { await stopServer({ server, closeDb, tempDir }); }); - it('returns empty projects when resume is missing', async () => { - const res = await fetch(`${baseUrl}/api/profile/projects`); - const body = await res.json(); + describe('GET /api/profile/projects', () => { + it('returns projects when profile is configured', async () => { + const mockProfile = { + sections: { + projects: { + items: [ + { id: 'proj1', name: 'Project 1', description: 'Desc 1', date: '2024', visible: true }, + { id: 'proj2', name: 'Project 2', description: 'Desc 2', date: '2023', visible: false }, + ], + }, + }, + }; + vi.mocked(getProfile).mockResolvedValue(mockProfile); - expect(res.ok).toBe(true); - expect(body.success).toBe(true); - expect(body.data).toEqual([]); + const res = await fetch(`${baseUrl}/api/profile/projects`); + const body = await res.json(); + + expect(res.ok).toBe(true); + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBe(2); + }); + + it('returns error when profile is not configured', async () => { + vi.mocked(getProfile).mockRejectedValue(new Error('Base resume not configured.')); + + const res = await fetch(`${baseUrl}/api/profile/projects`); + const body = await res.json(); + + expect(res.ok).toBe(false); + expect(body.success).toBe(false); + expect(body.error).toContain('Base resume not configured'); + }); }); - it('returns null profile when resume is missing', async () => { - const res = await fetch(`${baseUrl}/api/profile`); - const body = await res.json(); + describe('GET /api/profile', () => { + it('returns full profile when configured', async () => { + const mockProfile = { + basics: { name: 'Test User', headline: 'Developer' }, + sections: { summary: { content: 'A summary' } }, + }; + vi.mocked(getProfile).mockResolvedValue(mockProfile); - expect(res.ok).toBe(true); - expect(body.success).toBe(true); - expect(body.data).toBeNull(); + const res = await fetch(`${baseUrl}/api/profile`); + const body = await res.json(); + + expect(res.ok).toBe(true); + expect(body.success).toBe(true); + expect(body.data).toEqual(mockProfile); + }); + + it('returns error when profile is not configured', async () => { + vi.mocked(getProfile).mockRejectedValue(new Error('Base resume not configured.')); + + const res = await fetch(`${baseUrl}/api/profile`); + const body = await res.json(); + + expect(res.ok).toBe(false); + expect(body.success).toBe(false); + expect(body.error).toContain('Base resume not configured'); + }); }); - it('returns base resume projects', async () => { - // Create valid resume file first - const resumePath = join(tempDir, 'resume.json'); - await writeFile(resumePath, JSON.stringify(createMinimalValidResume())); - - const res = await fetch(`${baseUrl}/api/profile/projects`); - const body = await res.json(); - expect(body.success).toBe(true); - expect(Array.isArray(body.data)).toBe(true); - }); - - it('returns full base resume profile', async () => { - // Create valid resume file first - const resumePath = join(tempDir, 'resume.json'); - await writeFile(resumePath, JSON.stringify(createMinimalValidResume())); - - const res = await fetch(`${baseUrl}/api/profile`); - const body = await res.json(); - expect(body.success).toBe(true); - expect(body.data).toBeDefined(); - expect(typeof body.data).toBe('object'); - }); - - describe('GET /api/profile/status', () => { - it('returns exists: false when resume file does not exist', async () => { + it('returns exists: false when rxresumeBaseResumeId is not configured', async () => { + vi.mocked(getSetting).mockResolvedValue(null); + const res = await fetch(`${baseUrl}/api/profile/status`); const body = await res.json(); expect(res.ok).toBe(true); expect(body.success).toBe(true); expect(body.data.exists).toBe(false); - expect(body.data.error).toBeTruthy(); + expect(body.data.error).toContain('No base resume selected'); }); - it('returns exists: false when resume file is empty', async () => { - const resumePath = join(tempDir, 'resume.json'); - await writeFile(resumePath, ''); - - const res = await fetch(`${baseUrl}/api/profile/status`); - const body = await res.json(); - - expect(res.ok).toBe(true); - expect(body.data.exists).toBe(false); - }); - - it('returns exists: true when valid resume file exists', async () => { - const resumePath = join(tempDir, 'resume.json'); - await writeFile(resumePath, JSON.stringify(createMinimalValidResume())); + it('returns exists: true when resume is accessible', async () => { + vi.mocked(getSetting).mockResolvedValue('test-resume-id'); + vi.mocked(getResume).mockResolvedValue({ + id: 'test-resume-id', + data: { basics: { name: 'Test' } }, + } as any); const res = await fetch(`${baseUrl}/api/profile/status`); const body = await res.json(); @@ -94,160 +139,38 @@ describe.sequential('Profile API routes', () => { expect(body.data.exists).toBe(true); expect(body.data.error).toBeNull(); }); - }); - describe('POST /api/profile/upload', () => { - it('rejects request without profile payload', async () => { - const res = await fetch(`${baseUrl}/api/profile/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - }); - const body = await res.json(); + it('returns exists: false when RxResume credentials are missing', async () => { + vi.mocked(getSetting).mockResolvedValue('test-resume-id'); + vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError()); - expect(res.status).toBe(400); - expect(body.success).toBe(false); - expect(body.error).toContain('Invalid profile payload'); - }); - - it('rejects array as profile payload', async () => { - const res = await fetch(`${baseUrl}/api/profile/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile: [] }), - }); - const body = await res.json(); - - expect(res.status).toBe(400); - expect(body.success).toBe(false); - expect(body.error).toContain('Invalid profile payload'); - }); - - it('rejects primitive as profile payload', async () => { - const res = await fetch(`${baseUrl}/api/profile/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile: 'not an object' }), - }); - const body = await res.json(); - - expect(res.status).toBe(400); - expect(body.success).toBe(false); - expect(body.error).toContain('Invalid profile payload'); - }); - - it('rejects invalid resume with detailed field path in error', async () => { - const res = await fetch(`${baseUrl}/api/profile/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile: { foo: 'bar' } }), - }); - const body = await res.json(); - - expect(res.status).toBe(400); - expect(body.success).toBe(false); - expect(body.error).toContain('Invalid resume JSON'); - // Should include field path in error message - expect(body.error).toMatch(/Field "[^"]+"/); - }); - - it('accepts valid resume and creates file', async () => { - const validResume = createMinimalValidResume(); - const res = await fetch(`${baseUrl}/api/profile/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile: validResume }), - }); + const res = await fetch(`${baseUrl}/api/profile/status`); const body = await res.json(); expect(res.ok).toBe(true); expect(body.success).toBe(true); - expect(body.data.exists).toBe(true); - expect(body.data.error).toBeNull(); - - // Verify file was created - const resumePath = join(tempDir, 'resume.json'); - const fileInfo = await stat(resumePath); - expect(fileInfo.isFile()).toBe(true); - expect(fileInfo.size).toBeGreaterThan(0); + expect(body.data.exists).toBe(false); + expect(body.data.error).toContain('credentials not configured'); }); - it('overwrites existing resume file', async () => { - const resumePath = join(tempDir, 'resume.json'); - const oldResume = createMinimalValidResume(); - oldResume.basics.name = 'Old Name'; - await writeFile(resumePath, JSON.stringify(oldResume)); + it('returns exists: false when resume data is empty', async () => { + vi.mocked(getSetting).mockResolvedValue('test-resume-id'); + vi.mocked(getResume).mockResolvedValue({ + id: 'test-resume-id', + data: null, + } as any); - const newResume = createMinimalValidResume(); - newResume.basics.name = 'New Name'; - const res = await fetch(`${baseUrl}/api/profile/upload`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ profile: newResume }), - }); + const res = await fetch(`${baseUrl}/api/profile/status`); const body = await res.json(); + expect(res.ok).toBe(true); expect(body.success).toBe(true); - - // Verify profile was updated - const profileRes = await fetch(`${baseUrl}/api/profile`); - const profileBody = await profileRes.json(); - expect(profileBody.data.basics.name).toBe('New Name'); + expect(body.data.exists).toBe(false); + expect(body.data.error).toContain('empty or invalid'); }); }); + + // Note: POST /api/profile/refresh tests skipped because basic auth blocks POST in test environment + // The endpoint is tested indirectly through the profile service tests }); - -/** - * Creates a minimal valid RxResume v4 schema compliant JSON - */ -function createMinimalValidResume() { - return { - basics: { - name: 'Test User', - headline: 'Software Developer', - email: 'test@example.com', - phone: '', - location: '', - url: { label: '', href: '' }, - customFields: [], - picture: { - url: '', - size: 64, - aspectRatio: 1, - borderRadius: 0, - effects: { hidden: false, border: false, grayscale: false }, - }, - }, - sections: { - summary: { id: 'summary', name: 'Summary', columns: 1, separateLinks: true, visible: true, content: '' }, - skills: { id: 'skills', name: 'Skills', columns: 1, separateLinks: true, visible: true, items: [] }, - awards: { id: 'awards', name: 'Awards', columns: 1, separateLinks: true, visible: true, items: [] }, - certifications: { id: 'certifications', name: 'Certifications', columns: 1, separateLinks: true, visible: true, items: [] }, - education: { id: 'education', name: 'Education', columns: 1, separateLinks: true, visible: true, items: [] }, - experience: { id: 'experience', name: 'Experience', columns: 1, separateLinks: true, visible: true, items: [] }, - volunteer: { id: 'volunteer', name: 'Volunteer', columns: 1, separateLinks: true, visible: true, items: [] }, - interests: { id: 'interests', name: 'Interests', columns: 1, separateLinks: true, visible: true, items: [] }, - languages: { id: 'languages', name: 'Languages', columns: 1, separateLinks: true, visible: true, items: [] }, - profiles: { id: 'profiles', name: 'Profiles', columns: 1, separateLinks: true, visible: true, items: [] }, - projects: { id: 'projects', name: 'Projects', columns: 1, separateLinks: true, visible: true, items: [] }, - publications: { id: 'publications', name: 'Publications', columns: 1, separateLinks: true, visible: true, items: [] }, - references: { id: 'references', name: 'References', columns: 1, separateLinks: true, visible: true, items: [] }, - custom: {}, - }, - metadata: { - template: 'rhyhorn', - layout: [[['summary'], ['skills']]], - css: { value: '', visible: false }, - page: { margin: 18, format: 'a4', options: { breakLine: true, pageNumbers: true } }, - theme: { background: '#ffffff', text: '#000000', primary: '#dc2626' }, - typography: { - font: { family: 'IBM Plex Serif', subset: 'latin', variants: ['regular'], size: 14 }, - lineHeight: 1.5, - hideIcons: false, - underlineLinks: true, - }, - notes: '', - }, - }; -} diff --git a/orchestrator/src/server/services/pdf-skills-validation.test.ts b/orchestrator/src/server/services/pdf-skills-validation.test.ts index 2fabd34..ff8e971 100644 --- a/orchestrator/src/server/services/pdf-skills-validation.test.ts +++ b/orchestrator/src/server/services/pdf-skills-validation.test.ts @@ -1,6 +1,6 @@ - import { describe, it, expect, vi, beforeEach } from 'vitest'; import { generatePdf } from './pdf.js'; +import { getProfile } from './profile.js'; // Define mock data in hoisted block const { mocks, mockProfile, mockRxResumeClient } = vi.hoisted(() => { @@ -85,6 +85,11 @@ vi.mock('../repositories/settings.js', () => ({ getAllSettings: vi.fn().mockResolvedValue({}), })); +// Mock the profile service - getProfile now fetches from v4 API +vi.mock('./profile.js', () => ({ + getProfile: vi.fn().mockResolvedValue(mockProfile), +})); + vi.mock('./projectSelection.js', () => ({ pickProjectIdsForJob: vi.fn().mockResolvedValue([]), })); @@ -138,7 +143,7 @@ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ describe('PDF Service Skills Validation', () => { beforeEach(() => { vi.clearAllMocks(); - mocks.readFile.mockResolvedValue(JSON.stringify(mockProfile)); + vi.mocked(getProfile).mockResolvedValue(mockProfile); mockRxResumeClient.clearLastCreateData(); }); @@ -194,7 +199,7 @@ describe('PDF Service Skills Validation', () => { } } }; - mocks.readFile.mockResolvedValueOnce(JSON.stringify(invalidProfile)); + vi.mocked(getProfile).mockResolvedValueOnce(invalidProfile); // No tailoring, pass dummy path to bypass getProfile cache and use readFile mock await generatePdf('job-no-tailor', {}, 'Job Desc', 'dummy.json'); @@ -225,7 +230,7 @@ describe('PDF Service Skills Validation', () => { } } }; - mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds)); + vi.mocked(getProfile).mockResolvedValueOnce(profileWithoutIds); await generatePdf('job-cuid2-test', {}, 'Job Desc', 'dummy.json'); @@ -262,7 +267,7 @@ describe('PDF Service Skills Validation', () => { } } }; - mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithoutIds)); + vi.mocked(getProfile).mockResolvedValueOnce(profileWithoutIds); await generatePdf('job-no-skill-prefix', {}, 'Job Desc', 'dummy.json'); @@ -291,7 +296,7 @@ describe('PDF Service Skills Validation', () => { } } }; - mocks.readFile.mockResolvedValueOnce(JSON.stringify(profileWithValidId)); + vi.mocked(getProfile).mockResolvedValueOnce(profileWithValidId); await generatePdf('job-preserve-id', {}, 'Job Desc', 'dummy.json'); diff --git a/orchestrator/src/server/services/pdf-tailoring.test.ts b/orchestrator/src/server/services/pdf-tailoring.test.ts index 3500de5..ff31730 100644 --- a/orchestrator/src/server/services/pdf-tailoring.test.ts +++ b/orchestrator/src/server/services/pdf-tailoring.test.ts @@ -88,6 +88,11 @@ vi.mock('../repositories/settings.js', () => ({ getAllSettings: vi.fn().mockResolvedValue({}), })); +// Mock the profile service - getProfile now fetches from v4 API +vi.mock('./profile.js', () => ({ + getProfile: vi.fn().mockResolvedValue(mockProfile), +})); + vi.mock('./projectSelection.js', () => ({ pickProjectIdsForJob: vi.fn().mockResolvedValue([]), })); diff --git a/orchestrator/src/server/services/rxresume-client.test.ts b/orchestrator/src/server/services/rxresume-client.test.ts index 59123dc..a5aee54 100644 --- a/orchestrator/src/server/services/rxresume-client.test.ts +++ b/orchestrator/src/server/services/rxresume-client.test.ts @@ -222,6 +222,7 @@ describe('RxResumeClient', () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, + headers: { get: vi.fn() }, json: async () => ({ accessToken: 'mock-token-123' }), }); vi.stubGlobal('fetch', mockFetch); @@ -235,6 +236,7 @@ describe('RxResumeClient', () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, + headers: { get: vi.fn() }, json: async () => ({ data: { accessToken: 'nested-token' } }), }); vi.stubGlobal('fetch', mockFetch); @@ -248,6 +250,7 @@ describe('RxResumeClient', () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, + headers: { get: vi.fn() }, json: async () => ({ token: 'alt-token-field' }), }); vi.stubGlobal('fetch', mockFetch); @@ -274,6 +277,7 @@ describe('RxResumeClient', () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, + headers: { get: vi.fn() }, json: async () => ({ user: { id: '123' } }), }); vi.stubGlobal('fetch', mockFetch); @@ -489,6 +493,7 @@ describe('RxResumeClient', () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, status: 200, + headers: { get: vi.fn() }, json: async () => ({ accessToken: 'token' }), }); vi.stubGlobal('fetch', mockFetch); diff --git a/orchestrator/src/server/tailoring-flow.test.ts b/orchestrator/src/server/tailoring-flow.test.ts index ad78874..1042096 100644 --- a/orchestrator/src/server/tailoring-flow.test.ts +++ b/orchestrator/src/server/tailoring-flow.test.ts @@ -51,7 +51,7 @@ describe('Tailoring Flow', () => { skills: ['React', 'TypeScript', 'Vitest'] }), 'Senior TypeScript Developer', // Original JD - expect.any(String), // Profile path + undefined, // Deprecated profile path 'project-a,project-c' // The manually selected projects ); }); @@ -78,7 +78,7 @@ describe('Tailoring Flow', () => { skills: [] }), 'Junior Java Developer', - expect.any(String), + undefined, // Deprecated profile path undefined // No projects selected ); }); From 65a139a5ae2f09d8c7b7c6d4ab980cc760c14a76 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 12:33:40 +0000 Subject: [PATCH 15/18] remove unused folder --- resume-generator/generate_summary.py | 139 ------------------ resume-generator/rxresume_automation.py | 184 ------------------------ 2 files changed, 323 deletions(-) delete mode 100644 resume-generator/generate_summary.py delete mode 100644 resume-generator/rxresume_automation.py diff --git a/resume-generator/generate_summary.py b/resume-generator/generate_summary.py deleted file mode 100644 index 0c12065..0000000 --- a/resume-generator/generate_summary.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -Generate a tailored résumé summary using AI (OpenRouter API). -""" - -import os -import json -import requests -import pyperclip -from dotenv import load_dotenv - - -def load_profile(path: str = "./base.json") -> dict: - """Load the user's profile from a JSON file.""" - with open(path, "r", encoding="utf-8") as f: - return json.load(f) - - -def load_job_description(from_clipboard: bool = True, path: str = None) -> str: - """ - Load the job description from clipboard or a file. - - Args: - from_clipboard: If True, read from system clipboard - path: If from_clipboard is False, read from this file path - - Returns: - The job description text - """ - if from_clipboard: - return pyperclip.paste().strip() - if path: - with open(path, "r", encoding="utf-8") as f: - return f.read().strip() - raise ValueError("No job description source provided.") - - -def _build_prompt(profile: dict, jd: str) -> str: - """Build the prompt for the AI model.""" - return f""" -You are generating a tailored résumé summary for me. - -Requirements: -- Use keywords found in the job description. -- Keep it concise but meaningful. Avoid fluff. Avoid long-winded text. -- Include just enough detail to feel real and grounded. -- Gently convey that I care about helping people and doing good work. -- Do NOT invent experience or skills I don't have. -- Maintain a warm, confident, human tone. -- Target THIS specific job directly, so use ATS keywords, while remaining natural. -- Use the profile to add context and details. - -My profile (JSON fields merged): -{json.dumps(profile, indent=2)} - -Job description: -{jd} - -Write the résumé summary now. -""" - - -def _call_openrouter(prompt: str, model: str, api_key: str) -> str: - """Call OpenRouter API to generate text.""" - url = "https://openrouter.ai/api/v1/chat/completions" - - headers = { - "Authorization": f"Bearer {api_key}", - "HTTP-Referer": "http://localhost", - "X-Title": "ResumeSummaryScript", - "Content-Type": "application/json", - } - - payload = { - "model": model, - "messages": [{"role": "user", "content": prompt}], - "stream": False, - "plugins": [{"id": "response-healing"}], - } - - response = requests.post(url, headers=headers, json=payload) - - if response.status_code != 200: - raise RuntimeError(f"OpenRouter error {response.status_code}: {response.text}") - - data = response.json() - return data["choices"][0]["message"]["content"] - - -def generate_resume_summary( - profile_path: str = "./base.json", - job_description: str = None, - from_clipboard: bool = True, - copy_to_clipboard: bool = True, -) -> str: - """ - Generate a tailored résumé summary using AI. - - Uses the user's profile and a job description to generate a personalized - summary section for a résumé, targeting the specific job. - - Args: - profile_path: Path to the profile JSON file - job_description: Job description text (if None, uses from_clipboard/path) - from_clipboard: If job_description is None, read JD from clipboard - copy_to_clipboard: If True, copy the generated summary to clipboard - - Returns: - The generated résumé summary text - """ - load_dotenv() - - api_key = os.getenv("OPENROUTER_API_KEY") - model = os.getenv("MODEL", "google/gemini-3-flash-preview") - - if not api_key: - raise RuntimeError("Missing OPENROUTER_API_KEY in .env") - - profile = load_profile(profile_path) - - if job_description is None: - jd = load_job_description(from_clipboard=from_clipboard) - else: - jd = job_description - - prompt = _build_prompt(profile, jd) - summary = _call_openrouter(prompt, model, api_key) - - if copy_to_clipboard: - pyperclip.copy(summary) - - return summary - - -if __name__ == "__main__": - summary = generate_resume_summary() - - print("\n=== Generated Summary ===\n") - print(summary) - print("\n[Summary copied to clipboard]\n") diff --git a/resume-generator/rxresume_automation.py b/resume-generator/rxresume_automation.py deleted file mode 100644 index c86c32e..0000000 --- a/resume-generator/rxresume_automation.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -Automate RXResume (rxresu.me) to import resume and export PDF using Playwright. -""" - -import os -from pathlib import Path -from playwright.sync_api import sync_playwright - -# Configuration -RXRESUME_EMAIL = os.getenv("RXRESUME_EMAIL", "") -RXRESUME_PASSWORD = os.getenv("RXRESUME_PASSWORD", "") - -BASE_DIR = Path(__file__).parent - -# Allow override via environment variables (used by orchestrator) -_custom_json_path = os.getenv("RESUME_JSON_PATH") -RESUME_JSON_PATH = ( - Path(_custom_json_path) if _custom_json_path else BASE_DIR / "base.json" -) - -_custom_output_filename = os.getenv("OUTPUT_FILENAME") -OUTPUT_FILENAME = _custom_output_filename if _custom_output_filename else "resume.pdf" - -# Output directory - can be overridden by orchestrator -_custom_output_dir = os.getenv("OUTPUT_DIR") -OUTPUT_DIR = Path(_custom_output_dir) if _custom_output_dir else BASE_DIR / "resumes" - - -def login(page): - """Log in to RXResume.""" - page.goto("https://v4.rxresu.me/auth/login") - page.fill('input[placeholder="john.doe@example.com"]', RXRESUME_EMAIL) - page.fill('input[type="password"]', RXRESUME_PASSWORD) - page.click('button:has-text("Sign in")') - page.wait_for_url("**/dashboard/resumes", timeout=15000) - page.click('button:has-text("List")') - - -def import_resume(page, json_path: Path): - """Import a resume JSON file.""" - # Log the JSON file size for debugging - try: - import json - with open(json_path, 'r') as f: - data = json.load(f) - print(f" 📋 JSON keys: {list(data.keys())}") - if 'basics' in data: - print(f" 📋 Headline: {data['basics'].get('headline', 'N/A')[:50]}...") - except Exception as e: - print(f" ⚠️ Could not read JSON for logging: {e}") - - page.click('h4:has-text("Import")') - page.set_input_files('input[type="file"]', str(json_path)) - page.click('button:has-text("Validate")') - - # Wait for validation to complete - check for either success (Import button) or error - try: - # Wait for the Import button to become visible (validation succeeded) - page.wait_for_selector('button:has-text("Import"):not([disabled])', timeout=10000) - except Exception as e: - # Save debug files to errors folder (accessible outside Docker) - errors_dir = OUTPUT_DIR.parent / "errors" - errors_dir.mkdir(parents=True, exist_ok=True) - - # Take a screenshot for debugging - try: - screenshot_path = errors_dir / f"debug_{json_path.stem}.png" - page.screenshot(path=str(screenshot_path)) - print(f" 📸 Debug screenshot saved: {screenshot_path}") - except Exception as screenshot_err: - print(f" ⚠️ Could not save screenshot: {screenshot_err}") - - # Copy the failed JSON to errors folder for inspection - try: - import shutil - failed_json_path = errors_dir / f"{json_path.stem}.json" - shutil.copy(str(json_path), str(failed_json_path)) - print(f" 📋 Failed JSON saved: {failed_json_path}") - except Exception as copy_err: - print(f" ⚠️ Could not save failed JSON: {copy_err}") - - # Check for validation error messages in the dialog - error_selectors = [ - 'text=/error|invalid|failed/i', - '[class*="error"]', - '[class*="destructive"]', - '.text-red-500', - '.text-destructive', - '[role="alert"]', - ] - for selector in error_selectors: - error_element = page.query_selector(selector) - if error_element: - error_text = error_element.inner_text().strip() - if error_text: - print(f" ❌ RXResume validation error: {error_text}") - raise RuntimeError(f"RXResume validation failed: {error_text}") - - # Log what's visible in the dialog for debugging - dialog = page.query_selector('[role="dialog"]') - if dialog: - dialog_text = dialog.inner_text()[:500] - print(f" 📋 Dialog content: {dialog_text}") - - raise RuntimeError(f"Import button not found after validation (timeout): {e}") - - page.click('button:has-text("Import")') - - -def navigate_to_top_resume(page): - """Navigate to the first resume in the editor.""" - if "/dashboard/resumes" not in page.url: - page.goto("https://v4.rxresu.me/dashboard/resumes") - page.wait_for_load_state("networkidle") - - # wait a beat for the list to update - page.wait_for_timeout(1000) - page.click('span[data-state="closed"]:first-of-type div:first-of-type') - page.wait_for_url("**/builder/**", timeout=10000) - - -def export_pdf(page, output_path: Path) -> Path: - """Export the resume as PDF.""" - page.wait_for_timeout(1500) # Wait for builder to fully load - - selector = "div.inline-flex.items-center.justify-center.rounded-full.bg-background.px-4.shadow-xl button:last-of-type" - - with page.expect_download(timeout=30000) as download_info: - page.click(selector) - - download = download_info.value - output_path.parent.mkdir(parents=True, exist_ok=True) - download.save_as(str(output_path)) - return output_path - - -def generate_resume_pdf( - output_filename: str = None, - import_json: bool = True, - json_path: Path = None, -) -> Path: - """ - Import resume and export PDF. - - Args: - output_filename: Name of the output PDF file (defaults to OUTPUT_FILENAME env var) - import_json: Whether to import a JSON file first (default True) - json_path: Path to JSON file (defaults to RESUME_JSON_PATH env var) - - Returns: - Path to the generated PDF - """ - # Use environment-provided defaults - actual_filename = output_filename or OUTPUT_FILENAME - actual_json_path = json_path or RESUME_JSON_PATH - output_path = OUTPUT_DIR / actual_filename - - print(f"📄 Generating PDF: {actual_filename}") - print(f" JSON source: {actual_json_path}") - - with sync_playwright() as playwright: - browser = playwright.firefox.launch(headless=True) - context = browser.new_context() - page = context.new_page() - - try: - login(page) - - if import_json: - import_resume(page, actual_json_path) - - navigate_to_top_resume(page) - export_pdf(page, output_path) - finally: - browser.close() - - print(f"✅ PDF saved: {output_path}") - return output_path - - -if __name__ == "__main__": - # When run directly, use environment variables or defaults - pdf_path = generate_resume_pdf() - print(f"Done! PDF saved: {pdf_path}") From 442e600b583ee3a6a26c77252c99e1bd0eb1b474 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:36:07 +0000 Subject: [PATCH 16/18] Update orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../client/pages/settings/components/ReactiveResumeSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx index eded825..07a4550 100644 --- a/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ReactiveResumeSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react" +import React from "react" import { Controller, useFormContext } from "react-hook-form" import { AlertCircle, CheckCircle2 } from "lucide-react" From 852e2ff6fc5a06ffacdea87d105b70c18281b209 Mon Sep 17 00:00:00 2001 From: Shaheer Sarfaraz <53654735+DaKheera47@users.noreply.github.com> Date: Fri, 23 Jan 2026 12:36:25 +0000 Subject: [PATCH 17/18] Update orchestrator/src/server/api/routes/profile.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- orchestrator/src/server/api/routes/profile.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/orchestrator/src/server/api/routes/profile.test.ts b/orchestrator/src/server/api/routes/profile.test.ts index ed1b5c5..9feebc8 100644 --- a/orchestrator/src/server/api/routes/profile.test.ts +++ b/orchestrator/src/server/api/routes/profile.test.ts @@ -30,7 +30,7 @@ vi.mock('../../repositories/settings.js', async (importOriginal) => { }); import { getResume, RxResumeCredentialsError } from '../../services/rxresume-v4.js'; -import { getProfile, clearProfileCache } from '../../services/profile.js'; +import { getProfile } from '../../services/profile.js'; import { getSetting } from '../../repositories/settings.js'; describe.sequential('Profile API routes', () => { From 1dd5a34447dfa1326ef1a9d93ec535cd84858803 Mon Sep 17 00:00:00 2001 From: DaKheera47 Date: Fri, 23 Jan 2026 12:41:00 +0000 Subject: [PATCH 18/18] comments --- .../server/services/rxresume-client.test.ts | 37 +++++++++++++++++++ .../src/server/services/rxresume-v5.ts | 8 +--- orchestrator/src/shared/settings-schema.ts | 2 +- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/orchestrator/src/server/services/rxresume-client.test.ts b/orchestrator/src/server/services/rxresume-client.test.ts index a5aee54..872f966 100644 --- a/orchestrator/src/server/services/rxresume-client.test.ts +++ b/orchestrator/src/server/services/rxresume-client.test.ts @@ -260,6 +260,43 @@ describe('RxResumeClient', () => { expect(token).toBe('alt-token-field'); }); + it('extracts token from set-cookie header when missing from body', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: vi.fn().mockReturnValue(null), + getSetCookie: vi + .fn() + .mockReturnValue(['Authentication=cookie-token; Path=/; HttpOnly']), + }, + json: async () => ({}), + }); + vi.stubGlobal('fetch', mockFetch); + + const token = await client.login('test@example.com', 'password123'); + + expect(token).toBe('cookie-token'); + }); + + it('extracts token from set-cookie string header fallback', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { + get: vi + .fn() + .mockReturnValue('Authentication=string-token; Path=/; HttpOnly'), + }, + json: async () => ({}), + }); + vi.stubGlobal('fetch', mockFetch); + + const token = await client.login('test@example.com', 'password123'); + + expect(token).toBe('string-token'); + }); + it('throws error on login failure', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: false, diff --git a/orchestrator/src/server/services/rxresume-v5.ts b/orchestrator/src/server/services/rxresume-v5.ts index b11b908..114b02d 100644 --- a/orchestrator/src/server/services/rxresume-v5.ts +++ b/orchestrator/src/server/services/rxresume-v5.ts @@ -34,8 +34,6 @@ async function executeWithKeyRetries(url: string, options: RequestInit): Promise ? rawApiKey.split(',').map(k => k.trim()) : [rawApiKey]; - let lastError: Error | null = null; - // Start from the last working key index for (let attempt = 0; attempt < apiKeys.length; attempt++) { const i = (lastWorkingKeyIndex + attempt) % apiKeys.length; @@ -74,9 +72,7 @@ async function executeWithKeyRetries(url: string, options: RequestInit): Promise } return response.text(); } catch (error) { - lastError = error as Error; - - // If it was already handled by the 401 check above, it won't reach here + // If it was already handled by the 401 check above, it won't reach here // because of the 'continue'. This catch is for network errors or unexpected throw. throw error; } @@ -94,7 +90,7 @@ async function executeWithKeyRetries(url: string, options: RequestInit): Promise `); } - throw lastError || new Error('All Reactive Resume API keys failed.'); + throw new Error('All Reactive Resume API keys failed.'); } /** diff --git a/orchestrator/src/shared/settings-schema.ts b/orchestrator/src/shared/settings-schema.ts index 5801642..3398421 100644 --- a/orchestrator/src/shared/settings-schema.ts +++ b/orchestrator/src/shared/settings-schema.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const resumeProjectsSchema = z.object({ - maxProjects: z.number().int().min(1).max(100), + maxProjects: z.number().int().min(0).max(100), lockedProjectIds: z.array(z.string().trim().min(1)).max(200), aiSelectableProjectIds: z.array(z.string().trim().min(1)).max(200), });