ready panel now works with external resume json instead of local
This commit is contained in:
parent
7a358db317
commit
9dfb862649
@ -176,6 +176,19 @@ export async function getProfileProjects(): Promise<ResumeProjectCatalogItem[]>
|
||||
return fetchApi<ResumeProjectCatalogItem[]>('/profile/projects');
|
||||
}
|
||||
|
||||
export async function getResumeProjectsCatalog(): Promise<ResumeProjectCatalogItem[]> {
|
||||
try {
|
||||
const settings = await getSettings();
|
||||
if (settings.rxresumeBaseResumeId) {
|
||||
return await getRxResumeProjects(settings.rxresumeBaseResumeId);
|
||||
}
|
||||
} catch {
|
||||
// fall through to profile-based projects
|
||||
}
|
||||
|
||||
return getProfileProjects();
|
||||
}
|
||||
|
||||
export async function getProfile(): Promise<ResumeProfile> {
|
||||
return fetchApi<ResumeProfile>('/profile');
|
||||
}
|
||||
@ -184,10 +197,9 @@ export async function getProfileStatus(): Promise<ProfileStatusResponse> {
|
||||
return fetchApi<ProfileStatusResponse>('/profile/status');
|
||||
}
|
||||
|
||||
export async function uploadProfile(profile: ResumeProfile): Promise<ProfileStatusResponse> {
|
||||
return fetchApi<ProfileStatusResponse>('/profile/upload', {
|
||||
export async function refreshProfile(): Promise<ResumeProfile> {
|
||||
return fetchApi<ResumeProfile>('/profile/refresh', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ profile }),
|
||||
});
|
||||
}
|
||||
|
||||
@ -205,7 +217,7 @@ export async function validateRxresume(email?: string, password?: string): Promi
|
||||
});
|
||||
}
|
||||
|
||||
export async function validateResumeJson(): Promise<ValidationResult> {
|
||||
export async function validateResumeConfig(): Promise<ValidationResult> {
|
||||
return fetchApi<ValidationResult>('/onboarding/validate/resume');
|
||||
}
|
||||
|
||||
|
||||
@ -79,7 +79,7 @@ export const ReadyPanel: React.FC<ReadyPanelProps> = ({
|
||||
|
||||
// Load project catalog once
|
||||
useEffect(() => {
|
||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
// Reset mode when job changes
|
||||
|
||||
@ -55,7 +55,7 @@ export const TailoringEditor: React.FC<TailoringEditorProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
// Load project catalog
|
||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
|
||||
|
||||
// Set initial selection
|
||||
if (job.selectedProjectIds) {
|
||||
|
||||
@ -41,7 +41,7 @@ export const TailorMode: React.FC<TailorModeProps> = ({
|
||||
const [showDescription, setShowDescription] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.getProfileProjects().then(setCatalog).catch(console.error);
|
||||
api.getResumeProjectsCatalog().then(setCatalog).catch(console.error);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { readFile, stat } from 'fs/promises';
|
||||
|
||||
import { resumeDataSchema } from '@shared/rxresume-schema.js';
|
||||
import { DEFAULT_PROFILE_PATH } from '@server/services/profile.js';
|
||||
import { RxResumeClient } from '@server/services/rxresume-client.js';
|
||||
import { getSetting } from '@server/repositories/settings.js';
|
||||
import { getResume, RxResumeCredentialsError } from '@server/services/rxresume-v4.js';
|
||||
|
||||
export const onboardingRouter = Router();
|
||||
|
||||
@ -55,29 +55,51 @@ async function validateOpenrouter(apiKey?: string | null): Promise<ValidationRes
|
||||
}
|
||||
}
|
||||
|
||||
async function validateResumeJson(): Promise<ValidationResponse> {
|
||||
/**
|
||||
* Validate that a base resume is configured and accessible via RxResume v4 API.
|
||||
*/
|
||||
async function validateResumeConfig(): Promise<ValidationResponse> {
|
||||
try {
|
||||
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
|
||||
if (!fileInfo.isFile() || fileInfo.size === 0) {
|
||||
return { valid: false, message: 'Resume JSON is missing.' };
|
||||
// Check if rxresumeBaseResumeId is configured
|
||||
const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId');
|
||||
|
||||
if (!rxresumeBaseResumeId) {
|
||||
return {
|
||||
valid: false,
|
||||
message: 'No base resume selected. Please select a resume from your RxResume account in Settings.'
|
||||
};
|
||||
}
|
||||
|
||||
const raw = await readFile(DEFAULT_PROFILE_PATH, 'utf-8');
|
||||
const parsed = JSON.parse(raw);
|
||||
const result = resumeDataSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
const issue = result.error.issues[0];
|
||||
const path = issue?.path?.join('.') || '';
|
||||
const baseMessage = issue?.message ?? 'Resume JSON does not match the expected schema.';
|
||||
const details = path
|
||||
? `Field "${path}": ${baseMessage}`
|
||||
: baseMessage;
|
||||
return { valid: false, message: details };
|
||||
}
|
||||
// Verify the resume is accessible and valid
|
||||
try {
|
||||
const resume = await getResume(rxresumeBaseResumeId);
|
||||
|
||||
return { valid: true, message: null };
|
||||
if (!resume.data || typeof resume.data !== 'object') {
|
||||
return { valid: false, message: 'Selected resume is empty or invalid.' };
|
||||
}
|
||||
|
||||
// Validate against schema
|
||||
const result = resumeDataSchema.safeParse(resume.data);
|
||||
if (!result.success) {
|
||||
const issue = result.error.issues[0];
|
||||
const path = issue?.path?.join('.') || '';
|
||||
const baseMessage = issue?.message ?? 'Resume does not match the expected schema.';
|
||||
const details = path
|
||||
? `Field "${path}": ${baseMessage}`
|
||||
: baseMessage;
|
||||
return { valid: false, message: details };
|
||||
}
|
||||
|
||||
return { valid: true, message: null };
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
return { valid: false, message: 'RxResume credentials not configured.' };
|
||||
}
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch resume from RxResume.';
|
||||
return { valid: false, message };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unable to read resume JSON.';
|
||||
const message = error instanceof Error ? error.message : 'Resume validation failed.';
|
||||
return { valid: false, message };
|
||||
}
|
||||
}
|
||||
@ -119,6 +141,6 @@ onboardingRouter.post('/validate/rxresume', async (req: Request, res: Response)
|
||||
});
|
||||
|
||||
onboardingRouter.get('/validate/resume', async (_req: Request, res: Response) => {
|
||||
const result = await validateResumeJson();
|
||||
const result = await validateResumeConfig();
|
||||
res.json({ success: true, data: result });
|
||||
});
|
||||
|
||||
@ -1,30 +1,16 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { mkdir, stat, writeFile } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { extractProjectsFromProfile } from '../../services/resumeProjects.js';
|
||||
import { clearProfileCache, DEFAULT_PROFILE_PATH, getProfile } from '../../services/profile.js';
|
||||
import { resumeDataSchema } from '@shared/rxresume-schema.js';
|
||||
import { getProfile, clearProfileCache } from '../../services/profile.js';
|
||||
import { getSetting } from '../../repositories/settings.js';
|
||||
import { getResume, RxResumeCredentialsError } from '../../services/rxresume-v4.js';
|
||||
|
||||
export const profileRouter = Router();
|
||||
|
||||
async function profileExists(): Promise<boolean> {
|
||||
try {
|
||||
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
|
||||
return fileInfo.isFile() && fileInfo.size > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/profile/projects - Get all projects available in the base resume
|
||||
*/
|
||||
profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await profileExists())) {
|
||||
res.json({ success: true, data: [] });
|
||||
return;
|
||||
}
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
res.json({ success: true, data: catalog });
|
||||
@ -39,10 +25,6 @@ profileRouter.get('/projects', async (req: Request, res: Response) => {
|
||||
*/
|
||||
profileRouter.get('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!(await profileExists())) {
|
||||
res.json({ success: true, data: null });
|
||||
return;
|
||||
}
|
||||
const profile = await getProfile();
|
||||
res.json({ success: true, data: profile });
|
||||
} catch (error) {
|
||||
@ -52,13 +34,51 @@ profileRouter.get('/', async (req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/profile/status - Check if base resume exists
|
||||
* GET /api/profile/status - Check if base resume is configured and accessible
|
||||
*/
|
||||
profileRouter.get('/status', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const fileInfo = await stat(DEFAULT_PROFILE_PATH);
|
||||
const exists = fileInfo.isFile() && fileInfo.size > 0;
|
||||
res.json({ success: true, data: { exists, error: exists ? null : 'Resume file is empty' } });
|
||||
const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId');
|
||||
|
||||
if (!rxresumeBaseResumeId) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
exists: false,
|
||||
error: 'No base resume selected. Please select a resume from your RxResume account in Settings.'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the resume is accessible
|
||||
try {
|
||||
const resume = await getResume(rxresumeBaseResumeId);
|
||||
if (!resume.data || typeof resume.data !== 'object') {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
exists: false,
|
||||
error: 'Selected resume is empty or invalid.'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, data: { exists: true, error: null } });
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
exists: false,
|
||||
error: 'RxResume credentials not configured.'
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.json({ success: true, data: { exists: false, error: message } });
|
||||
@ -66,43 +86,15 @@ profileRouter.get('/status', async (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/profile/upload - Upload base resume JSON
|
||||
* POST /api/profile/refresh - Clear profile cache and refetch from RxResume v4 API
|
||||
*/
|
||||
profileRouter.post('/upload', async (req: Request, res: Response) => {
|
||||
profileRouter.post('/refresh', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const profile = (req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>).profile : null) as unknown;
|
||||
|
||||
if (!profile || typeof profile !== 'object' || Array.isArray(profile)) {
|
||||
throw new Error('Invalid profile payload. Expected a JSON object.');
|
||||
}
|
||||
|
||||
const parsed = resumeDataSchema.safeParse(profile);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
const path = issue?.path?.join('.') || '';
|
||||
const baseMessage = issue?.message ?? 'Resume JSON does not match the RxResume schema.';
|
||||
const details = path ? `Field "${path}": ${baseMessage}` : baseMessage;
|
||||
throw new Error(`Invalid resume JSON: ${details}`);
|
||||
}
|
||||
|
||||
const existing = await stat(DEFAULT_PROFILE_PATH).catch(() => null);
|
||||
if (existing && existing.isDirectory()) {
|
||||
throw new Error('Resume path is a directory. Remove it and upload again.');
|
||||
}
|
||||
|
||||
await mkdir(dirname(DEFAULT_PROFILE_PATH), { recursive: true });
|
||||
await writeFile(DEFAULT_PROFILE_PATH, JSON.stringify(parsed.data, null, 2), 'utf-8');
|
||||
clearProfileCache();
|
||||
|
||||
res.json({ success: true, data: { exists: true, error: null } });
|
||||
const profile = await getProfile(true);
|
||||
res.json({ success: true, data: profile });
|
||||
} catch (error) {
|
||||
let message = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (error && typeof error === 'object' && 'code' in error) {
|
||||
const code = (error as { code?: string }).code;
|
||||
if (code === 'EROFS') {
|
||||
message = 'Resume path is read-only. Remove the bind mount and restart the container.';
|
||||
}
|
||||
}
|
||||
res.status(400).json({ success: false, error: message });
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
|
||||
@ -69,35 +69,8 @@ settingsRouter.patch('/', async (req: Request, res: Response) => {
|
||||
promises.push(settingsRepo.setSetting('resumeProjects', null));
|
||||
} else {
|
||||
promises.push((async () => {
|
||||
const baseResumeId = 'rxresumeBaseResumeId' in input
|
||||
? normalizeEnvInput(input.rxresumeBaseResumeId)
|
||||
: await settingsRepo.getSetting('rxresumeBaseResumeId');
|
||||
|
||||
let profile: Record<string, unknown> = {};
|
||||
|
||||
if (baseResumeId) {
|
||||
try {
|
||||
const resume = await getResume(baseResumeId);
|
||||
if (resume.data && typeof resume.data === 'object') {
|
||||
profile = resume.data as Record<string, unknown>;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
throw new Error('RxResume credentials missing while validating resume projects.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(profile).length === 0) {
|
||||
const rawProfile = await getProfile();
|
||||
|
||||
if (rawProfile === null || typeof rawProfile !== 'object' || Array.isArray(rawProfile)) {
|
||||
throw new Error('Invalid resume profile format: expected a non-null object');
|
||||
}
|
||||
|
||||
profile = rawProfile as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// getProfile() will fetch from RxResume v4 API using rxresumeBaseResumeId
|
||||
const profile = await getProfile();
|
||||
const { catalog } = extractProjectsFromProfile(profile);
|
||||
const allowed = new Set(catalog.map((p) => p.id));
|
||||
const normalized = normalizeResumeProjectsSettings(resumeProjects, allowed);
|
||||
|
||||
@ -14,7 +14,7 @@ import { runUkVisaJobs } from '../services/ukvisajobs.js';
|
||||
import { scoreJobSuitability } from '../services/scorer.js';
|
||||
import { generateTailoring } from '../services/summary.js';
|
||||
import { generatePdf } from '../services/pdf.js';
|
||||
import { DEFAULT_PROFILE_PATH, getProfile } from '../services/profile.js';
|
||||
import { getProfile } from '../services/profile.js';
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { pickProjectIdsForJob } from '../services/projectSelection.js';
|
||||
import { extractProjectsFromProfile, resolveResumeProjectsSettings } from '../services/resumeProjects.js';
|
||||
@ -30,7 +30,6 @@ const DEFAULT_CONFIG: PipelineConfig = {
|
||||
topN: 10,
|
||||
minSuitabilityScore: 50,
|
||||
sources: ['gradcracker', 'indeed', 'linkedin', 'ukvisajobs'],
|
||||
profilePath: DEFAULT_PROFILE_PATH,
|
||||
outputDir: join(getDataDir(), 'pdfs'),
|
||||
enableCrawling: true,
|
||||
enableScoring: true,
|
||||
@ -108,7 +107,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
try {
|
||||
// Step 1: Load profile
|
||||
console.log('\n📋 Loading profile...');
|
||||
const profile = await getProfile(mergedConfig.profilePath).catch((error) => {
|
||||
const profile = await getProfile().catch((error) => {
|
||||
console.warn('⚠️ Failed to load profile for scoring, using empty profile:', error);
|
||||
return {} as Record<string, unknown>;
|
||||
});
|
||||
@ -348,7 +347,7 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
|
||||
// Process job (Generate Summary + PDF)
|
||||
// We catch errors here to ensure one failure doesn't stop the whole batch
|
||||
const result = await processJob(job.id, { profilePath: mergedConfig.profilePath });
|
||||
const result = await processJob(job.id, { force: false });
|
||||
|
||||
if (result.success) {
|
||||
processedCount++;
|
||||
@ -417,7 +416,6 @@ export async function runPipeline(config: Partial<PipelineConfig> = {}): Promise
|
||||
|
||||
export type ProcessJobOptions = {
|
||||
force?: boolean;
|
||||
profilePath?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -436,7 +434,7 @@ export async function summarizeJob(
|
||||
const job = await jobsRepo.getJobById(jobId);
|
||||
if (!job) return { success: false, error: 'Job not found' };
|
||||
|
||||
const profile = await getProfile(options?.profilePath);
|
||||
const profile = await getProfile();
|
||||
|
||||
// 1. Generate Summary & Tailoring
|
||||
let tailoredSummary = job.tailoredSummary;
|
||||
@ -520,7 +518,7 @@ export async function generateFinalPdf(
|
||||
skills: job.tailoredSkills ? JSON.parse(job.tailoredSkills) : []
|
||||
},
|
||||
job.jobDescription || '',
|
||||
options?.profilePath || DEFAULT_PROFILE_PATH,
|
||||
undefined, // deprecated baseResumePath parameter
|
||||
job.selectedProjectIds
|
||||
);
|
||||
|
||||
|
||||
@ -93,7 +93,7 @@ export async function generatePdf(
|
||||
jobId: string,
|
||||
tailoredContent: TailoredPdfContent,
|
||||
jobDescription: string,
|
||||
baseResumePath?: string,
|
||||
_baseResumePath?: string, // Deprecated: now always uses getProfile() which fetches from v4 API
|
||||
selectedProjectIds?: string | null
|
||||
): Promise<PdfResult> {
|
||||
console.log(`📄 Generating PDF for job ${jobId} using RxResume v4 API...`);
|
||||
@ -108,10 +108,8 @@ export async function generatePdf(
|
||||
const { email, password, baseUrl } = await getCredentials();
|
||||
const client = new RxResumeClient(baseUrl);
|
||||
|
||||
// Read base resume
|
||||
const baseResume = baseResumePath
|
||||
? JSON.parse(await import('fs/promises').then(fs => fs.readFile(baseResumePath, 'utf-8')))
|
||||
: JSON.parse(JSON.stringify(await getProfile()));
|
||||
// Read base resume from profile (fetches from v4 API if configured)
|
||||
const baseResume = JSON.parse(JSON.stringify(await getProfile()));
|
||||
|
||||
// Sanitize skills: Ensure all skills have required schema fields (visible, description, id, level, keywords)
|
||||
// This fixes issues where the base JSON uses a shorthand format (missing required fields)
|
||||
|
||||
@ -1,32 +1,100 @@
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { readFile } from 'fs/promises';
|
||||
import { getProfile } from './profile.js';
|
||||
import { getProfile, clearProfileCache } from './profile.js';
|
||||
|
||||
vi.mock('fs/promises', async () => {
|
||||
const fn = vi.fn();
|
||||
return {
|
||||
readFile: fn,
|
||||
default: {
|
||||
readFile: fn
|
||||
// Mock the dependencies
|
||||
vi.mock('../repositories/settings.js', () => ({
|
||||
getSetting: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./rxresume-v4.js', () => ({
|
||||
getResume: vi.fn(),
|
||||
RxResumeCredentialsError: class RxResumeCredentialsError extends Error {
|
||||
constructor() {
|
||||
super('RxResume credentials not configured.');
|
||||
this.name = 'RxResumeCredentialsError';
|
||||
}
|
||||
};
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
describe('getProfile failure', () => {
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { getResume, RxResumeCredentialsError } from './rxresume-v4.js';
|
||||
|
||||
describe('getProfile', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
clearProfileCache();
|
||||
});
|
||||
|
||||
it('should throw an error if the profile file does not exist', async () => {
|
||||
vi.mocked(readFile).mockRejectedValue(new Error('ENOENT: no such file or directory'));
|
||||
it('should throw an error if rxresumeBaseResumeId is not configured', async () => {
|
||||
vi.mocked(getSetting).mockResolvedValue(null);
|
||||
|
||||
await expect(getProfile('/non/existent/path.json', true)).rejects.toThrow('ENOENT: no such file or directory');
|
||||
await expect(getProfile()).rejects.toThrow(
|
||||
'Base resume not configured. Please select a base resume from your RxResume account in Settings.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error if the profile file is invalid JSON', async () => {
|
||||
vi.mocked(readFile).mockResolvedValue('invalid json');
|
||||
it('should fetch profile from RxResume v4 API when configured', async () => {
|
||||
const mockResumeData = { basics: { name: 'Test User' } };
|
||||
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: 'test-resume-id',
|
||||
data: mockResumeData
|
||||
} as any);
|
||||
|
||||
await expect(getProfile('/invalid/json.json', true)).rejects.toThrow();
|
||||
const profile = await getProfile();
|
||||
|
||||
expect(getSetting).toHaveBeenCalledWith('rxresumeBaseResumeId');
|
||||
expect(getResume).toHaveBeenCalledWith('test-resume-id');
|
||||
expect(profile).toEqual(mockResumeData);
|
||||
});
|
||||
|
||||
it('should cache the profile and not refetch on subsequent calls', async () => {
|
||||
const mockResumeData = { basics: { name: 'Test User' } };
|
||||
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: 'test-resume-id',
|
||||
data: mockResumeData
|
||||
} as any);
|
||||
|
||||
await getProfile();
|
||||
await getProfile();
|
||||
|
||||
// getSetting is called each time to check resumeId
|
||||
expect(getSetting).toHaveBeenCalledTimes(2);
|
||||
// But getResume should only be called once due to caching
|
||||
expect(getResume).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should refetch when forceRefresh is true', async () => {
|
||||
const mockResumeData = { basics: { name: 'Test User' } };
|
||||
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: 'test-resume-id',
|
||||
data: mockResumeData
|
||||
} as any);
|
||||
|
||||
await getProfile();
|
||||
await getProfile(true);
|
||||
|
||||
expect(getResume).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should throw user-friendly error on credential issues', async () => {
|
||||
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
|
||||
vi.mocked(getResume).mockRejectedValue(new RxResumeCredentialsError());
|
||||
|
||||
await expect(getProfile()).rejects.toThrow(
|
||||
'RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if resume data is empty', async () => {
|
||||
vi.mocked(getSetting).mockResolvedValue('test-resume-id');
|
||||
vi.mocked(getResume).mockResolvedValue({
|
||||
id: 'test-resume-id',
|
||||
data: null
|
||||
} as any);
|
||||
|
||||
await expect(getProfile()).rejects.toThrow('Resume data is empty or invalid');
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,33 +1,56 @@
|
||||
import { readFile } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
/**
|
||||
* Profile service - fetches resume data from RxResume v4 API.
|
||||
*
|
||||
* The rxresumeBaseResumeId setting is REQUIRED for the app to function.
|
||||
* There is no local file fallback.
|
||||
*/
|
||||
|
||||
import { getDataDir } from '../config/dataDir.js';
|
||||
|
||||
export const DEFAULT_PROFILE_PATH = process.env.RESUME_PROFILE_PATH || join(getDataDir(), 'resume.json');
|
||||
import { getSetting } from '../repositories/settings.js';
|
||||
import { getResume, RxResumeCredentialsError } from './rxresume-v4.js';
|
||||
|
||||
let cachedProfile: any = null;
|
||||
let cachedProfilePath: string | null = null;
|
||||
let cachedResumeId: string | null = null;
|
||||
|
||||
/**
|
||||
* Get the base resume profile from resume.json.
|
||||
* Caches the result since it doesn't change often.
|
||||
* @param profilePath Optional absolute path to profile JSON. Defaults to base.json.
|
||||
* @param forceRefresh Force reload from disk.
|
||||
* Get the base resume profile from RxResume v4 API.
|
||||
*
|
||||
* Requires rxresumeBaseResumeId to be configured in settings.
|
||||
* Results are cached until clearProfileCache() is called.
|
||||
*
|
||||
* @param forceRefresh Force reload from API.
|
||||
* @throws Error if rxresumeBaseResumeId is not configured or API call fails.
|
||||
*/
|
||||
export async function getProfile(profilePath?: string, forceRefresh = false): Promise<any> {
|
||||
const targetPath = profilePath || DEFAULT_PROFILE_PATH;
|
||||
export async function getProfile(forceRefresh = false): Promise<any> {
|
||||
const rxresumeBaseResumeId = await getSetting('rxresumeBaseResumeId');
|
||||
|
||||
if (cachedProfile && cachedProfilePath === targetPath && !forceRefresh) {
|
||||
if (!rxresumeBaseResumeId) {
|
||||
throw new Error(
|
||||
'Base resume not configured. Please select a base resume from your RxResume account in Settings.'
|
||||
);
|
||||
}
|
||||
|
||||
// Return cached profile if valid
|
||||
if (cachedProfile && cachedResumeId === rxresumeBaseResumeId && !forceRefresh) {
|
||||
return cachedProfile;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await readFile(targetPath, 'utf-8');
|
||||
cachedProfile = JSON.parse(content);
|
||||
cachedProfilePath = targetPath;
|
||||
console.log(`📋 Fetching profile from RxResume v4 API (resume: ${rxresumeBaseResumeId})...`);
|
||||
const resume = await getResume(rxresumeBaseResumeId);
|
||||
|
||||
if (!resume.data || typeof resume.data !== 'object') {
|
||||
throw new Error('Resume data is empty or invalid');
|
||||
}
|
||||
|
||||
cachedProfile = resume.data;
|
||||
cachedResumeId = rxresumeBaseResumeId;
|
||||
console.log(`✅ Profile loaded from RxResume v4 API`);
|
||||
return cachedProfile;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load profile from ${targetPath}:`, error);
|
||||
if (error instanceof RxResumeCredentialsError) {
|
||||
throw new Error('RxResume credentials not configured. Set RXRESUME_EMAIL and RXRESUME_PASSWORD in settings.');
|
||||
}
|
||||
console.error(`❌ Failed to load profile from RxResume v4 API:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -45,4 +68,5 @@ export async function getPersonName(): Promise<string> {
|
||||
*/
|
||||
export function clearProfileCache(): void {
|
||||
cachedProfile = null;
|
||||
cachedResumeId = null;
|
||||
}
|
||||
|
||||
@ -174,7 +174,6 @@ export interface PipelineConfig {
|
||||
topN: number; // Number of top jobs to process
|
||||
minSuitabilityScore: number; // Minimum score to auto-process
|
||||
sources: JobSource[]; // Job sources to crawl
|
||||
profilePath: string; // Path to profile JSON
|
||||
outputDir: string; // Directory for generated PDFs
|
||||
enableCrawling?: boolean;
|
||||
enableScoring?: boolean;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user