From 5044047beeaf35eae1e293bfc418b891f8210f4b Mon Sep 17 00:00:00 2001 From: lina elman Date: Sat, 7 Jun 2025 12:05:50 +0300 Subject: [PATCH 1/2] added get resume by owner & added filename to resume model --- .../src/controllers/resume_controller.ts | 21 +++++++++++++++++-- nextstep-backend/src/models/resume_model.ts | 2 +- nextstep-backend/src/routes/resume_routes.ts | 2 ++ .../src/services/resume_service.ts | 3 ++- nextstep-frontend/src/pages/MainDashboard.tsx | 19 ++++++++++++++--- 5 files changed, 40 insertions(+), 7 deletions(-) diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 8adbf77..16da0ee 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -108,7 +108,7 @@ const parseResume = async (req: CustomRequest, res: Response) => { const resumeFilename = req.body.resumefileName; const parsed = await parseResumeFields(getResumeBuffer(req.body.resumefileName), resumeFilename); - const resumeData = await saveParsedResume(parsed, req.user.id, resumeFilename); + const resumeData = await saveParsedResume(parsed, req.user.id, resumeFilename, req.body.originfilename); return res.status(200).json(parsed); } catch (err: any) { @@ -117,6 +117,23 @@ const parseResume = async (req: CustomRequest, res: Response) => { } }; +const getResume = async (req: CustomRequest, res: Response) => { + try { + const ownerId = req.user.id; + const resume = await getResumeByOwner(ownerId); + + if (!resume) { + return res.status(404).json({ error: 'Resume not found' }); + } + + return res.status(200).json(resume); + } catch (error) { + console.error('Error retrieving resume:', error); + return handleError(error, res); + } +} + + const getResumeData = async (req: CustomRequest, res: Response) => { try { const ownerId = req.user.id; @@ -133,4 +150,4 @@ const getResumeData = async (req: CustomRequest, res: Response) => { export default { parseResume, getResumeScore, getStreamResumeScore, getTemplates, - generateResume, getResumeData }; \ No newline at end of file + generateResume, getResumeData, getResume }; \ No newline at end of file diff --git a/nextstep-backend/src/models/resume_model.ts b/nextstep-backend/src/models/resume_model.ts index 4a3499a..5abc7d8 100644 --- a/nextstep-backend/src/models/resume_model.ts +++ b/nextstep-backend/src/models/resume_model.ts @@ -7,11 +7,11 @@ const ResumeSchema = new Schema({ rawContentLink: { type: String, required: true }, parsedData: { type: { + fileName: { type: String, required: false }, aboutMe: { type: String, required: false }, skills: { type: [String], required: false }, roleMatch: { type: String, required: false }, experience: { type: [String], required: false } - }, required: false }, diff --git a/nextstep-backend/src/routes/resume_routes.ts b/nextstep-backend/src/routes/resume_routes.ts index 9bb93bd..73b27f6 100644 --- a/nextstep-backend/src/routes/resume_routes.ts +++ b/nextstep-backend/src/routes/resume_routes.ts @@ -21,5 +21,7 @@ router.post('/parseResume', upload.single('resume'), (req: Request, res: Respon // TODO - Use it in the frontend after the parse and upload resume router.get('/resumeData/:version', (req: Request, res: Response) => Resume.getResumeData(req as CustomRequest, res)) +router.get('/', (req: Request, res: Response) => Resume.getResume(req as CustomRequest, res)) + export default router; \ No newline at end of file diff --git a/nextstep-backend/src/services/resume_service.ts b/nextstep-backend/src/services/resume_service.ts index 1a22f8b..674a44c 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -343,7 +343,7 @@ const getLatestResumeByUser = async (ownerId: string): Promise => { }; -const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resumeRawLink: string): Promise => { +const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resumeRawLink: string, filename: string): Promise => { const lastVersion = await getLatestResumeByUser(ownerId); const newVersion = lastVersion + 1; @@ -352,6 +352,7 @@ const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resum version: newVersion, rawContentLink: resumeRawLink, parsedData: { + fileName: filename, aboutMe: parsedData.aboutMe, skills: parsedData.skills, roleMatch: parsedData.roleMatch, diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 2aa9f2d..2a6811c 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -57,7 +57,7 @@ const MainDashboard: React.FC = () => { const [useOAuth, setUseOAuth] = useState(true); const [showAuthOptions, setShowAuthOptions] = useState(false); - // AI-resume state + // resume state const [parsing, setParsing] = useState(false); const [resumeExperience, setResumeExperience] = useState([]); const [roleMatch, setRoleMatch] = useState(''); @@ -101,6 +101,19 @@ const MainDashboard: React.FC = () => { } }, []); + // Fetch resume data on mount + useEffect(() => { + const fetchResumeData = async () => { + try { + const response = await api.get('/resume'); + setResumeFileName(response.data.parsedData.fileName || ''); + } catch (err) { + console.error('Failed to fetch resume data:', err); + } + }; + fetchResumeData(); + }, []); + const mergeRepoLanguages = async (fetchedRepos: typeof repos) => { const langSet = new Set(skills); for (const repo of fetchedRepos) { @@ -158,11 +171,11 @@ const MainDashboard: React.FC = () => { const form = new FormData(); form.append('file', file); try { - const uplaodedResume = await uploadResume(form); + const uploadedResume = await uploadResume(form); const res = await api.post('/resume/parseResume', { - resumefileName: uplaodedResume, + resumefileName: uploadedResume, originfilename: file.name, }, { headers: { 'Content-Type': 'multipart/form-data' }, }); From da0c68d54bc24b1cff3bfdd22f770781a2979457 Mon Sep 17 00:00:00 2001 From: lina elman Date: Sat, 7 Jun 2025 20:29:53 +0300 Subject: [PATCH 2/2] added changes to support resume page integration with server --- .../src/controllers/resume_controller.ts | 41 ++++++++++++++-- nextstep-backend/src/models/resume_model.ts | 5 +- nextstep-backend/src/routes/resume_routes.ts | 2 +- .../src/services/resume_service.ts | 31 ++++++++++-- nextstep-backend/src/types/resume_types.ts | 4 ++ nextstep-frontend/src/pages/MainDashboard.tsx | 2 + nextstep-frontend/src/pages/Resume.tsx | 49 +++++++++++++++---- 7 files changed, 115 insertions(+), 19 deletions(-) diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts index 16da0ee..1d2e196 100644 --- a/nextstep-backend/src/controllers/resume_controller.ts +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -4,12 +4,19 @@ import fs from 'fs'; import path from 'path'; import { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields, - saveParsedResume, getResumeByOwner } from '../services/resume_service'; + saveParsedResume, getResumeByOwner, updateResume } from '../services/resume_service'; import multer from 'multer'; -import {getResumeBuffer, resumeExists, uploadResume} from '../services/resources_service'; +import {getResumeBuffer, resumeExists} from '../services/resources_service'; import { CustomRequest } from "types/customRequest"; import { handleError } from "../utils/handle_error"; +// Simple in-memory cache: { [key: string]: { scoreAndFeedback, timestamp } } +const resumeScoreCache: Record = {}; +const CACHE_TTL_MS = 24* 60 * 60 * 1000; // 24 hour + +const getCacheKey = (filename: string, jobDescription?: string) => + `${filename}::${jobDescription || ''}`; + const getResumeScore = async (req: Request, res: Response) => { try { const { filename } = req.params; @@ -20,7 +27,14 @@ const getResumeScore = async (req: Request, res: Response) => { return res.status(404).send('Resume not found'); } + const cacheKey = getCacheKey(filename, jobDescription); + const cached = resumeScoreCache[cacheKey]; + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return res.status(200).send(cached.data); + } + const scoreAndFeedback = await scoreResume(resumePath, jobDescription); + resumeScoreCache[cacheKey] = { data: scoreAndFeedback, timestamp: Date.now() }; return res.status(200).send(scoreAndFeedback); } catch (error) { if (error instanceof TypeError) { @@ -31,7 +45,7 @@ const getResumeScore = async (req: Request, res: Response) => { } }; -const getStreamResumeScore = async (req: Request, res: Response) => { +const getStreamResumeScore = async (req: CustomRequest, res: Response) => { try { const { filename } = req.params; const resumePath = path.resolve(config.resources.resumesDirectoryPath(), filename); @@ -41,6 +55,18 @@ const getStreamResumeScore = async (req: Request, res: Response) => { return res.status(404).send('Resume not found'); } + const cacheKey = getCacheKey(filename, jobDescription); + const cached = resumeScoreCache[cacheKey]; + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + // Stream cached result as SSE + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.write(`data: ${JSON.stringify({ ...cached.data, done: true })}\n\n`); + res.end(); + return; + } + // Set headers for SSE res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); @@ -52,10 +78,12 @@ const getStreamResumeScore = async (req: Request, res: Response) => { }); // Stream the response - const score = await streamScoreResume( + let fullChunk = ''; + const [score, fullText] = await streamScoreResume( resumePath, jobDescription, (chunk) => { + fullChunk += chunk; res.write(`data: ${JSON.stringify({ chunk })}\n\n`); } ); @@ -63,6 +91,11 @@ const getStreamResumeScore = async (req: Request, res: Response) => { // Send the final score res.write(`data: ${JSON.stringify({ score, done: true })}\n\n`); res.end(); + + // Optionally cache the result (score and fullText) + resumeScoreCache[cacheKey] = { data: { score, fullText }, timestamp: Date.now() }; + await updateResume(req.user.id, jobDescription, fullText, score); + } catch (error) { if (error instanceof TypeError) { return res.status(400).send(error.message); diff --git a/nextstep-backend/src/models/resume_model.ts b/nextstep-backend/src/models/resume_model.ts index 5abc7d8..75a4250 100644 --- a/nextstep-backend/src/models/resume_model.ts +++ b/nextstep-backend/src/models/resume_model.ts @@ -11,7 +11,10 @@ const ResumeSchema = new Schema({ aboutMe: { type: String, required: false }, skills: { type: [String], required: false }, roleMatch: { type: String, required: false }, - experience: { type: [String], required: false } + experience: { type: [String], required: false }, + jobDescription: { type: String, required: false }, + feedback: { type: String, required: false }, + score: { type: Number, required: false }, }, required: false }, diff --git a/nextstep-backend/src/routes/resume_routes.ts b/nextstep-backend/src/routes/resume_routes.ts index 73b27f6..fdfd9f6 100644 --- a/nextstep-backend/src/routes/resume_routes.ts +++ b/nextstep-backend/src/routes/resume_routes.ts @@ -10,7 +10,7 @@ const router = express.Router(); router.get('/score/:filename', Resume.getResumeScore); -router.get('/streamScore/:filename', Resume.getStreamResumeScore); +router.get('/streamScore/:filename', (req: Request, res: Response) => Resume.getStreamResumeScore(req as CustomRequest, res)); router.get('/templates', Resume.getTemplates); diff --git a/nextstep-backend/src/services/resume_service.ts b/nextstep-backend/src/services/resume_service.ts index 674a44c..51de80f 100644 --- a/nextstep-backend/src/services/resume_service.ts +++ b/nextstep-backend/src/services/resume_service.ts @@ -58,7 +58,7 @@ const streamScoreResume = async ( resumePath: string, jobDescription: string | undefined, onChunk: (chunk: string) => void -): Promise => { +): Promise<[number, string]> => { try { const resumeText = await parseDocument(resumePath); if (resumeText.trim() == '') { @@ -88,7 +88,7 @@ const streamScoreResume = async ( onChunk(FEEDBACK_ERROR_MESSAGE); } - return finalScore; + return [finalScore, fullResponse]; } catch (error: any) { if (error instanceof TypeError) { console.error('TypeError while streaming resume score:', error); @@ -364,6 +364,31 @@ const saveParsedResume = async (parsedData: ParsedResume, ownerId: string, resum return resumeToResumeData(savedResume); }; +const updateResume = async (ownerId: string, jobDescription: string, feedback?: string, score?: number, filename?: string): Promise => { + try { + const resume = await getResumeByOwner(ownerId); + if (!resume) { + throw new Error(`Resume not found`); + } + const parsedData = resume.parsedData as ParsedResume; // Ensure parsedData is of type ParsedResume + if (jobDescription !== parsedData.jobDescription) { + resume.parsedData = { + ...parsedData, + jobDescription: jobDescription || parsedData.jobDescription, + feedback: feedback || parsedData.feedback || '', + score: score || parsedData.score || 0, + fileName: parsedData.fileName || filename || '' + }; + resume.markModified('parsedData'); + await resume.save(); + + } + } + catch (error) { + console.error('Error updating resume:', error); + throw new Error(`Failed to update resume`); + } +} const getResumeByOwner = async (ownerId: string, version?: number) => { try { @@ -395,4 +420,4 @@ const getResumeByOwner = async (ownerId: string, version?: number) => { export { scoreResume, streamScoreResume, getResumeTemplates, generateImprovedResume, parseResumeFields, - saveParsedResume, getResumeByOwner }; \ No newline at end of file + saveParsedResume, getResumeByOwner, updateResume, }; \ No newline at end of file diff --git a/nextstep-backend/src/types/resume_types.ts b/nextstep-backend/src/types/resume_types.ts index 75791b3..4dd5248 100644 --- a/nextstep-backend/src/types/resume_types.ts +++ b/nextstep-backend/src/types/resume_types.ts @@ -7,6 +7,10 @@ export interface ParsedResume { roleMatch: string; experience: string[]; education?: string[]; + jobDescription?: string; + feedback?: string; + score?: number; + fileName?: string; } export interface ResumeDocument extends Document { diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 2a6811c..b8bd87d 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -107,6 +107,8 @@ const MainDashboard: React.FC = () => { try { const response = await api.get('/resume'); setResumeFileName(response.data.parsedData.fileName || ''); + setResumeExperience(response.data.parsedData.experience || []); + setRoleMatch(response.data.parsedData.roleMatch || ''); } catch (err) { console.error('Failed to fetch resume data:', err); } diff --git a/nextstep-frontend/src/pages/Resume.tsx b/nextstep-frontend/src/pages/Resume.tsx index 9ff670e..6b21eba 100644 --- a/nextstep-frontend/src/pages/Resume.tsx +++ b/nextstep-frontend/src/pages/Resume.tsx @@ -181,6 +181,7 @@ const GeneratedWordPreview: React.FC<{ base64Content: string }> = ({ base64Conte const Resume: React.FC = () => { const [activeStep, setActiveStep] = useState(0); const [file, setFile] = useState(null); + const [fileName, setFileName] = useState(''); const [jobDescription, setJobDescription] = useState(''); const [feedback, setFeedback] = useState(''); const [score, setScore] = useState(null); @@ -216,6 +217,26 @@ const Resume: React.FC = () => { fetchTemplates(); }, []); + // Fetch resume data on mount + useEffect(() => { + const fetchResumeData = async () => { + try { + const response = await api.get('/resume'); + if (response.data && response.data.parsedData) { + const parsedData = response.data.parsedData; + setJobDescription(parsedData.jobDescription || parsedData.aboutMe); + setFileName(parsedData.fileName || ''); + parsedData.feedback && setFeedback(parsedData.feedback); + parsedData.score && setScore(parsedData.score); + } + + } catch (err) { + console.error('Failed to fetch resume data:', err); + } + }; + fetchResumeData(); + }, []); + const uploadResume = async (formData: FormData) => { const response = await api.post('/resource/resume', formData, { headers: { @@ -228,6 +249,7 @@ const Resume: React.FC = () => { const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { setFile(event.target.files[0]); + setFileName(event.target.files[0].name); setError(''); } }; @@ -237,7 +259,7 @@ const Resume: React.FC = () => { }; const handleSubmit = async () => { - if (!file) { + if (!fileName) { setError('Please select a file'); return; } @@ -248,11 +270,17 @@ const Resume: React.FC = () => { setError(''); try { - const formData = new FormData(); - formData.append('file', file); - - const filename = await uploadResume(formData); + var filename = ''; + if (file) { + const formData = new FormData(); + formData.append('file', file); + filename = await uploadResume(formData); + } + else { + const resume = await api.get('/resume'); + filename = resume.data.rawContentLink.split('/').pop() || ''; + } const token = localStorage.getItem(config.localStorageKeys.userAuth) ? JSON.parse(localStorage.getItem(config.localStorageKeys.userAuth)!).accessToken : ''; @@ -269,7 +297,8 @@ const Resume: React.FC = () => { setScore(data.score); eventSource.close(); setLoading(false); - setActiveStep(1); // Move to next step after scoring + data.fullText && setFeedback(data.fullText); + // setActiveStep(1); // Move to next step after scoring } else if (data.chunk) { setFeedback(prev => prev + data.chunk); } @@ -462,8 +491,8 @@ const Resume: React.FC = () => { /> - {file ? ( - {file.name} + {fileName ? ( + {fileName} ) : ( Click to upload your resume )} @@ -479,7 +508,7 @@ const Resume: React.FC = () => {