ready panel now works with external resume json instead of local

This commit is contained in:
DaKheera47 2026-01-23 11:40:34 +00:00
parent 7a358db317
commit 9dfb862649
12 changed files with 250 additions and 164 deletions

View File

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

View File

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

View File

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

View File

@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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