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 {