v4 api based, with the same code facing api as v5

This commit is contained in:
DaKheera47 2026-01-23 00:55:44 +00:00
parent 11aab30b11
commit 4798846483
8 changed files with 648 additions and 165 deletions

View File

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

View File

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

View File

@ -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<any>) => {
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];

View File

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

View File

@ -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<void> {
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<PdfResult> {
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<void> {
return new Promise((resolve, reject) => {
// Use the virtual environment's Python (or system python in Docker)
const pythonPath = process.env.PYTHON_PATH || join(RESUME_GEN_DIR, '.venv', 'bin', 'python');
const child = spawn(pythonPath, ['rxresume_automation.py'], {
cwd: RESUME_GEN_DIR,
env: {
...process.env,
RESUME_JSON_PATH: jsonPath,
OUTPUT_FILENAME: outputFilename,
OUTPUT_DIR: outputDir,
},
stdio: 'inherit',
});
child.on('close', (code) => {
if (code === 0) {
resolve();
} else {
reject(new Error(`Python script exited with code ${code}`));
}
});
child.on('error', reject);
});
}
/**
* Check if a PDF exists for a job.
*/

View File

@ -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<string, unknown>;
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<string, string> {
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<string, CachedToken>();
// 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<string> {
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<T>(
identifier: string,
password: string,
operation: (token: string) => Promise<T>
): Promise<T> {
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<string> {
async create(
resumeData: unknown,
token: string,
options?: { title?: string; slug?: string }
): Promise<string> {
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<RxResumeResume[]> {
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<RxResumeResume> {
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;
}
}

View File

@ -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<RxResumeCredentials>
): Promise<RxResumeCredentials> {
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<T>(
override: Partial<RxResumeCredentials> | undefined,
operation: (client: RxResumeClient, token: string) => Promise<T>
): Promise<T> {
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<RxResumeCredentials>
): Promise<RxResumeResume[]> {
return withRxResumeClient(override, (client, token) => client.list(token));
}
export async function getResume(
resumeId: string,
override?: Partial<RxResumeCredentials>
): Promise<RxResumeResume> {
return withRxResumeClient(override, (client, token) => client.get(resumeId, token));
}
export async function importResume(
payload: RxResumeImportPayload,
override?: Partial<RxResumeCredentials>
): Promise<string> {
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<RxResumeCredentials>
): Promise<void> {
return withRxResumeClient(override, (client, token) => client.delete(resumeId, token));
}
export async function exportResumePdf(
resumeId: string,
override?: Partial<RxResumeCredentials>
): Promise<string> {
return withRxResumeClient(override, (client, token) => client.print(resumeId, token));
}

View File

@ -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 {