v4 api based, with the same code facing api as v5
This commit is contained in:
parent
11aab30b11
commit
4798846483
@ -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 });
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
105
orchestrator/src/server/services/rxresume-v4.ts
Normal file
105
orchestrator/src/server/services/rxresume-v4.ts
Normal 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));
|
||||
}
|
||||
@ -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 {
|
||||
Loading…
x
Reference in New Issue
Block a user