diff --git a/nextstep-backend/package.json b/nextstep-backend/package.json index a1e3ccb..67d1f63 100644 --- a/nextstep-backend/package.json +++ b/nextstep-backend/package.json @@ -20,6 +20,8 @@ }, "homepage": "https://github.com/NextStepFinalProject/NextStep#readme", "dependencies": { + "@types/pdf-parse": "^1.1.5", + "axios": "^1.8.3", "bcrypt": "^5.1.1", "body-parser": "^1.20.3", "cors": "^2.8.5", @@ -34,13 +36,15 @@ "jest-junit": "^16.0.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", + "mammoth": "^1.9.0", "mongoose": "^8.8.2", "multer": "^1.4.5-lts.1", + "office-text-extractor": "^3.0.3", + "pdf-lib": "^1.17.1", "socket.io": "^4.8.1", "supertest": "^7.0.0", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "axios": "^1.8.3" + "swagger-ui-express": "^5.0.1" }, "devDependencies": { "@babel/preset-env": "^7.26.0", diff --git a/nextstep-backend/src/app.ts b/nextstep-backend/src/app.ts index 2942509..371440b 100644 --- a/nextstep-backend/src/app.ts +++ b/nextstep-backend/src/app.ts @@ -14,7 +14,7 @@ import {config} from "./config/config"; import validateUser from "./middleware/validateUser"; import loadOpenApiFile from "./openapi/openapi_loader"; import resource_routes from './routes/resources_routes'; - +import resume_routes from './routes/resume_routes'; const specs = swaggerJsdoc(options); @@ -43,10 +43,8 @@ app.use(bodyParser.json()); app.use(removeUndefinedOrEmptyFields); app.use(bodyParser.urlencoded({ extended: true })); - app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(loadOpenApiFile() as JsonObject)); - // Add Authentication for all routes except the ones listed below app.use(authenticateToken.unless({ path: [ @@ -68,8 +66,6 @@ app.use(authenticateToken.unless({ // To block queries without Authentication app.use(authenticateTokenForParams); - - app.use('/auth', authRoutes); app.use('/comment', commentsRoutes); app.use('/post', postsRoutes); @@ -77,5 +73,6 @@ app.use("/user/:id", validateUser); app.use('/user', usersRoutes); app.use('/resource', resource_routes); app.use('/room', roomsRoutes); +app.use('/resume', resume_routes); export { app, corsOptions }; diff --git a/nextstep-backend/src/config/config.ts b/nextstep-backend/src/config/config.ts index fb749a3..cda46c2 100644 --- a/nextstep-backend/src/config/config.ts +++ b/nextstep-backend/src/config/config.ts @@ -16,7 +16,9 @@ export const config = { }, resources: { imagesDirectoryPath: () => 'resources/images', - imageMaxSize: () => 10 * 1024 * 1024 // Max file size: 10MB + imageMaxSize: () => 10 * 1024 * 1024, // Max file size: 10MB + resumesDirectoryPath: () => 'resources/resumes', + resumeMaxSize: () => 5 * 1024 * 1024 // Max file size: 5MB }, chatAi: { api_url: () => process.env.CHAT_AI_API_URL || 'https://openrouter.ai/api/v1/chat/completions', diff --git a/nextstep-backend/src/controllers/resources_controller.ts b/nextstep-backend/src/controllers/resources_controller.ts index a178a98..c59a52b 100644 --- a/nextstep-backend/src/controllers/resources_controller.ts +++ b/nextstep-backend/src/controllers/resources_controller.ts @@ -2,13 +2,12 @@ import { Request, Response } from 'express'; import { config } from '../config/config'; import fs from 'fs'; import path from 'path'; -import { uploadImage } from '../services/resources_service'; +import { uploadResume, uploadImage } from '../services/resources_service'; import multer from 'multer'; import {CustomRequest} from "types/customRequest"; import {updateUserById} from "../services/users_service"; import {handleError} from "../utils/handle_error"; - const createUserImageResource = async (req: CustomRequest, res: Response) => { try { const imageFilename = await uploadImage(req); @@ -49,4 +48,38 @@ const getImageResource = async (req: Request, res: Response) => { } }; -export default { createUserImageResource, createImageResource, getImageResource }; \ No newline at end of file +const createResumeResource = async (req: Request, res: Response) => { + try { + const resumeFilename = await uploadResume(req); + return res.status(201).send(resumeFilename); + } catch (error) { + if (error instanceof multer.MulterError || error instanceof TypeError) { + return res.status(400).send(error.message); + } else { + handleError(error, res); + } + } +}; + +const getResumeResource = async (req: Request, res: Response) => { + try { + const { filename } = req.params; + const resumePath = path.resolve(config.resources.resumesDirectoryPath(), filename); + + if (!fs.existsSync(resumePath)) { + return res.status(404).send('Resume not found'); + } + + res.sendFile(resumePath); + } catch (error) { + handleError(error, res); + } +}; + +export default { + createUserImageResource, + createImageResource, + getImageResource, + getResumeResource, + createResumeResource +}; \ No newline at end of file diff --git a/nextstep-backend/src/controllers/resume_controller.ts b/nextstep-backend/src/controllers/resume_controller.ts new file mode 100644 index 0000000..3b6d70f --- /dev/null +++ b/nextstep-backend/src/controllers/resume_controller.ts @@ -0,0 +1,75 @@ +import { Request, Response } from 'express'; +import { config } from '../config/config'; +import fs from 'fs'; +import path from 'path'; +import { scoreResume, streamScoreResume } from '../services/resume_service'; +import multer from 'multer'; +import { CustomRequest } from "types/customRequest"; +import { handleError } from "../utils/handle_error"; + +const getResumeScore = async (req: Request, res: Response) => { + try { + const { filename } = req.params; + const resumePath = path.resolve(config.resources.resumesDirectoryPath(), filename); + const jobDescription = req.query.jobDescription as string; + + if (!fs.existsSync(resumePath)) { + return res.status(404).send('Resume not found'); + } + + const scoreAndFeedback = await scoreResume(resumePath, jobDescription); + return res.status(200).send(scoreAndFeedback); + } catch (error) { + if (error instanceof TypeError) { + return res.status(400).send(error.message); + } else { + handleError(error, res); + } + } +}; + +const getStreamResumeScore = async (req: Request, res: Response) => { + try { + const { filename } = req.params; + const resumePath = path.resolve(config.resources.resumesDirectoryPath(), filename); + const jobDescription = req.query.jobDescription as string; + + if (!fs.existsSync(resumePath)) { + return res.status(404).send('Resume not found'); + } + + // Set headers for SSE + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + + // Handle client disconnect + req.on('close', () => { + res.end(); + }); + + // Stream the response + const score = await streamScoreResume( + resumePath, + jobDescription, + (chunk) => { + res.write(`data: ${JSON.stringify({ chunk })}\n\n`); + } + ); + + // Send the final score + res.write(`data: ${JSON.stringify({ score, done: true })}\n\n`); + res.end(); + } catch (error) { + if (error instanceof TypeError) { + return res.status(400).send(error.message); + } else { + handleError(error, res); + } + } +}; + +export default { + getResumeScore, + getStreamResumeScore +}; \ No newline at end of file diff --git a/nextstep-backend/src/middleware/auth.ts b/nextstep-backend/src/middleware/auth.ts index 56c8ddc..38b1dda 100644 --- a/nextstep-backend/src/middleware/auth.ts +++ b/nextstep-backend/src/middleware/auth.ts @@ -11,8 +11,16 @@ const getTokenFromHeader = (req: CustomRequest): string | undefined => { return authHeader?.split(' ')[1]; } +const getTokenFromQueryParams = (req: CustomRequest): string | undefined => { + return req.query.accessToken as string; +} + const authenticateTokenHandler: any & { unless: typeof unless } = async (req: CustomRequest, res: Response, next: NextFunction, ignoreExpiration = false): Promise => { - const token = getTokenFromHeader(req); + let token = getTokenFromHeader(req); + if (!token) { + // If the token was not found in the headers, fall back to the query params. + token = getTokenFromQueryParams(req); + } if (!token) { res.status(401).json({ message: 'Access token required' }); diff --git a/nextstep-backend/src/openapi/swagger.yaml b/nextstep-backend/src/openapi/swagger.yaml index a02d165..5b51507 100644 --- a/nextstep-backend/src/openapi/swagger.yaml +++ b/nextstep-backend/src/openapi/swagger.yaml @@ -17,6 +17,8 @@ tags: description: Operations related to uploading & downloading resources - name: Rooms description: Operations related to chat rooms + - name: Resume + description: Operations related to resume ATS scoring paths: /post: @@ -247,7 +249,6 @@ paths: '401': description: Unauthorized - Missing token - # Comment Routes /comment: get: tags: @@ -409,7 +410,6 @@ paths: '404': $ref: '#/components/responses/PostNotFound' - # Authentication Routes /auth/register: post: tags: @@ -577,6 +577,7 @@ paths: $ref: '#/components/responses/Unauthorized' '403': $ref: '#/components/responses/Forbidden' + /user/{userId}: get: tags: @@ -664,7 +665,6 @@ paths: '404': $ref: '#/components/responses/UserNotFound' - /resource/image/user: post: tags: @@ -783,6 +783,175 @@ paths: '500': description: Internal server error + /resource/resume: + post: + tags: + - Resources + summary: Upload a resume + security: + - BearerAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + description: The resume file to upload (PDF, DOC, DOCX, TXT/TEXT) + responses: + '201': + description: Resume uploaded successfully + content: + text/plain: + schema: + description: The name of the uploaded file + type: string + '400': + description: Bad request + content: + text/plain: + examples: + fileTooLarge: + value: "File too large" + invalidFileType: + value: "Invalid file type. Only PDF, DOC, DOCX and TXT/TEXT files are allowed" + noFileUploaded: + value: "No file uploaded" + '401': + description: Unauthorized + '500': + description: Internal server error + + /resource/resume/{filename}: + get: + tags: + - Resources + summary: Get resume file + security: + - BearerAuth: [] + parameters: + - name: filename + in: path + required: true + schema: + type: string + description: The filename of the resume + responses: + '200': + description: Resume file retrieved successfully + content: + application/pdf: + schema: + type: string + format: binary + application/msword: + schema: + type: string + format: binary + application/vnd.openxmlformats-officedocument.wordprocessingml.document: + schema: + type: string + format: binary + '401': + description: Unauthorized + '404': + description: Resume not found + '500': + description: Internal server error + + /resume/score/{filename}: + get: + tags: + - Resume + summary: Get score for an existing resume + security: + - BearerAuth: [] + parameters: + - name: filename + in: path + required: true + schema: + type: string + description: The filename of the resume + - name: jobDescription + in: query + required: false + schema: + type: string + description: Optional job description for scoring + responses: + '200': + description: Resume score retrieved successfully + content: + application/json: + schema: + type: object + properties: + score: + type: number + description: The ATS score of the resume + '400': + description: Bad request + content: + text/plain: + examples: + couldNotParseResumeFile: + value: "Could not parse the resume file" + '401': + description: Unauthorized + '404': + description: Resume not found + '500': + description: Internal server error + + /resume/streamScore/{filename}: + get: + tags: + - Resume + summary: Get stream score for an existing resume + security: + - BearerAuth: [] + parameters: + - name: filename + in: path + required: true + schema: + type: string + description: The filename of the resume + - name: jobDescription + in: query + required: false + schema: + type: string + description: Optional job description for scoring + responses: + '200': + description: Stream resume score retrieved successfully + content: + application/json: + schema: + type: object + properties: + score: + type: number + description: The stream ATS score of the resume + '400': + description: Bad request + content: + text/plain: + examples: + couldNotParseResumeFile: + value: "Could not parse the resume file" + '401': + description: Unauthorized + '404': + description: Resume not found + '500': + description: Internal server error + /room/user/{receiverUserId}: get: tags: diff --git a/nextstep-backend/src/routes/resources_routes.ts b/nextstep-backend/src/routes/resources_routes.ts index 4a24532..016d79b 100644 --- a/nextstep-backend/src/routes/resources_routes.ts +++ b/nextstep-backend/src/routes/resources_routes.ts @@ -1,4 +1,3 @@ - import express, {Request, Response} from 'express'; import Resource from '../controllers/resources_controller'; import {CustomRequest} from "types/customRequest"; @@ -11,4 +10,8 @@ router.post('/image', Resource.createImageResource); router.get('/image/:filename', Resource.getImageResource); +router.post('/resume', Resource.createResumeResource); + +router.get('/resume/:filename', Resource.getResumeResource); + export default router; \ No newline at end of file diff --git a/nextstep-backend/src/routes/resume_routes.ts b/nextstep-backend/src/routes/resume_routes.ts new file mode 100644 index 0000000..92b4611 --- /dev/null +++ b/nextstep-backend/src/routes/resume_routes.ts @@ -0,0 +1,11 @@ +import express, { Request, Response } from 'express'; +import Resume from '../controllers/resume_controller'; +import { CustomRequest } from "types/customRequest"; + +const router = express.Router(); + +router.get('/score/:filename', Resume.getResumeScore); + +router.get('/streamScore/:filename', Resume.getStreamResumeScore); + +export default router; \ No newline at end of file diff --git a/nextstep-backend/src/routes/rooms_routes.ts b/nextstep-backend/src/routes/rooms_routes.ts index 7805a99..61818d4 100644 --- a/nextstep-backend/src/routes/rooms_routes.ts +++ b/nextstep-backend/src/routes/rooms_routes.ts @@ -1,4 +1,3 @@ - import express, {Request, Response} from 'express'; import * as roomsController from '../controllers/rooms_controller'; import { CustomRequest } from 'types/customRequest'; diff --git a/nextstep-backend/src/services/chat_api_service.ts b/nextstep-backend/src/services/chat_api_service.ts index fc5edb3..a21eae8 100644 --- a/nextstep-backend/src/services/chat_api_service.ts +++ b/nextstep-backend/src/services/chat_api_service.ts @@ -5,9 +5,101 @@ const cleanResponse = (response: string): string => { return response.replace(/\\boxed{(.*?)}/g, "$1"); // Removes \boxed{} } +/** + * @example + * await streamChatWithAI( + * "How would you build the tallest building ever?", + * "You are a helpful assistant.", + * (chunk) => { + * // Handle each chunk of the response + * console.log(chunk); + * // Or update your UI with the chunk + * } + * ); + */ +export const streamChatWithAI = async ( + userMessageContent: string, + systemMessageContent: string, + onChunk: (chunk: string) => void +): Promise => { + try { + const API_URL = config.chatAi.api_url(); + const API_KEY = config.chatAi.api_key(); + const MODEL_NAME = config.chatAi.model_name(); + + const systemMessage = { + role: 'system', + content: systemMessageContent + }; + const userMessage = { + role: 'user', + content: userMessageContent + }; + + const response = await fetch(API_URL, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: MODEL_NAME, + messages: [systemMessage, userMessage], + stream: true, + }), + }); -export const chatWithAI = async (inputUserMessage: string)=> { + const reader = response.body?.getReader(); + if (!reader) { + throw new Error('Response body is not readable'); + } + + const decoder = new TextDecoder(); + let buffer = ''; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + // Append new chunk to buffer + buffer += decoder.decode(value, { stream: true }); + + // Process complete lines from buffer + while (true) { + const lineEnd = buffer.indexOf('\n'); + if (lineEnd === -1) break; + + const line = buffer.slice(0, lineEnd).trim(); + buffer = buffer.slice(lineEnd + 1); + + if (line.startsWith('data: ')) { + const data = line.slice(6); + if (data === '[DONE]') break; + + try { + const parsed = JSON.parse(data); + const content = parsed.choices[0].delta.content; + if (content) { + onChunk(content); + } + } catch (e) { + // Ignore invalid JSON + } + } + } + } + } finally { + reader.cancel(); + } + } catch (error) { + console.error("Error streaming with OpenRouter:", error); + throw error; + } +} + +export const chatWithAI = async (userMessageContent: string, systemMessageContent: string): Promise => { try { const API_URL = config.chatAi.api_url(); const API_KEY = config.chatAi.api_key(); @@ -15,15 +107,14 @@ export const chatWithAI = async (inputUserMessage: string)=> { const systemMessage = { role: 'system', - content: 'You are an AI assistant tasked with providing the first comment on forum posts. Your responses should be relevant, engaging, and encourage further discussion, also must be short, and you must answer if you know the answer. Ensure your comments are appropriate for the content and tone of the post. Also must answer in the language of the user post. answer short answers. dont ask questions to follow up' + content: systemMessageContent }; const userMessage = { role: 'user', - content: inputUserMessage + content: userMessageContent }; - const response = await axios.post( API_URL, { diff --git a/nextstep-backend/src/services/posts_service.ts b/nextstep-backend/src/services/posts_service.ts index 65ac591..643cb97 100644 --- a/nextstep-backend/src/services/posts_service.ts +++ b/nextstep-backend/src/services/posts_service.ts @@ -32,7 +32,7 @@ const addMisterAIComment = async (postId: string, postContent: string) => { misterAI = await usersService.addUser('misterai', 'securepassword', 'misterai@example.com', 'local'); } - const comment = await chatService.chatWithAI(postContent); + const comment = await chatService.chatWithAI(postContent, 'You are an AI assistant tasked with providing the first comment on forum posts. Your responses should be relevant, engaging, and encourage further discussion, also must be short, and you must answer if you know the answer. Ensure your comments are appropriate for the content and tone of the post. Also must answer in the language of the user post. answer short answers. dont ask questions to follow up'); const commentData: CommentData = { postId, owner: misterAI.id, content: comment }; const savedComment = await commentsService.addComment(commentData); return savedComment; diff --git a/nextstep-backend/src/services/resources_service.ts b/nextstep-backend/src/services/resources_service.ts index 925bd61..8370c26 100644 --- a/nextstep-backend/src/services/resources_service.ts +++ b/nextstep-backend/src/services/resources_service.ts @@ -3,9 +3,13 @@ import multer from 'multer'; import path from 'path'; import { randomUUID } from 'crypto'; import fs from 'fs'; +import { Request } from 'express'; -const createImagesStorage = () => { +interface MulterRequest extends Request { + file?: Express.Multer.File; +} +const createImagesStorage = () => { // Ensure the directory exists const imagesResourcesDir = config.resources.imagesDirectoryPath(); if (!fs.existsSync(imagesResourcesDir)) { @@ -23,8 +27,7 @@ const createImagesStorage = () => { } }); - // TODO - consider to make a Promise - const uploadImage = multer({ + return multer({ storage: imagesStorage, limits: { fileSize: config.resources.imageMaxSize() @@ -41,16 +44,68 @@ const createImagesStorage = () => { } } }); +}; + +const createResumesStorage = () => { + // Ensure the directory exists + const resumesDirectoryPath = config.resources.resumesDirectoryPath(); + if (!fs.existsSync(resumesDirectoryPath)) { + fs.mkdirSync(resumesDirectoryPath, { recursive: true }); + } + + const resumesStorage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, `${resumesDirectoryPath}/`); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname); + const id = randomUUID(); + cb(null, id + ext); + } + }); + + return multer({ + storage: resumesStorage, + limits: { + fileSize: config.resources.resumeMaxSize() + }, + fileFilter: (req, file, cb) => { + const allowedTypes = /pdf|doc|docx|txt|text/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (extname && mimetype) { + return cb(null, true); + } else { + return cb(new TypeError(`Invalid file type. Only PDF, DOC, DOCX and TXT/TEXT files are allowed.`)); + } + } + }); +}; - return uploadImage; +const uploadImage = (req: MulterRequest): Promise => { + return new Promise((resolve, reject) => { + createImagesStorage().single('file')(req, {} as any, (error) => { + if (error) { + if (error instanceof multer.MulterError || error instanceof TypeError) { + return reject(error); + } else if (!req.file) { + return reject(new TypeError('No file uploaded.')); + } else { + return reject(new Error('Internal Server Error')); + } + } + if (!req.file) { + return reject(new TypeError('No file uploaded.')); + } + resolve(req.file.filename); + }); + }); }; -// TODO - fix the type here -// @ts-ignore -const uploadImage = (req) : Promise => { +const uploadResume = (req: MulterRequest): Promise => { return new Promise((resolve, reject) => { - // @ts-ignore - createImagesStorage().single('file')(req, {}, (error) => { + createResumesStorage().single('file')(req, {} as any, (error) => { if (error) { if (error instanceof multer.MulterError || error instanceof TypeError) { return reject(error); @@ -60,9 +115,12 @@ const uploadImage = (req) : Promise => { return reject(new Error('Internal Server Error')); } } + if (!req.file) { + return reject(new TypeError('No file uploaded.')); + } resolve(req.file.filename); }); }); }; -export { uploadImage }; \ No newline at end of file +export { uploadImage, uploadResume }; \ No newline at end of file diff --git a/nextstep-backend/src/services/resume_service.ts b/nextstep-backend/src/services/resume_service.ts new file mode 100644 index 0000000..5fbbdfd --- /dev/null +++ b/nextstep-backend/src/services/resume_service.ts @@ -0,0 +1,169 @@ +import { config } from '../config/config'; +import path from 'path'; +import fs from 'fs'; +import axios from 'axios'; +import { chatWithAI, streamChatWithAI } from './chat_api_service'; +import mammoth from 'mammoth'; +import pdfParse from 'pdf-parse'; + +const SYSTEM_TEMPLATE = `You are a very experienced ATS (Application Tracking System) bot with a deep understanding named BOb the Resume builder. +You will review resumes with or without job descriptions. +You are an expert in resume evaluation and provide constructive feedback with dynamic evaluation. +You should also provide an improvement table, taking into account: +- Content (Medium priority) +- Keyword matching (High priority) +- Hard skills (High priority) +- Soft skills (High priority) +- Overall presentation (Low priority)`; + +const feedbackTemplate = (resumeText: string, jdText: string) => ` +Resume Feedback Report +Here is the resume you provided: +${resumeText} +And the job description: +${jdText} + +Create the Improvement Table in relevance to the resume and give the consideration and suggestion for each section strictly following +the pattern as below and don't just out this guided pattern : +| Area | Consideration | Status | Suggestions | +| ------------- | --------------------------------------------------------------- | ------ | ----------- | +| Content | Measurable Results: At least 5 specific achievements or impact. | Low | | +| | Words to avoid: Negative phrases or clichés. | | | +| Keywords | Hard Skills: Presence and frequency of hard skills. | High | | +| | Soft Skills: Presence and frequency of soft skills. | | | +| Presentation | Education Match: Does the resume list a degree that matches the job requirements? | High | | + +Strengths: +List the strengths of the resume here. + +Detailed Feedback: +Provide detailed feedback on the resume's content, structure, grammar, and relevance to the job description. + +Suggestions: +Provide actionable suggestions for improvement, including specific keywords to include and skills to highlight. + +Based on your analysis, provide a numerical score between 0-100 that represents the overall quality and match of the resume. +The score should be provided at the end of your response in the format: "SCORE: X" where X is the numerical score. +`; + +const FEEDBACK_ERROR_MESSAGE = 'The Chat AI feature is turned off. Could not score your resume.'; + +const parseDocument = async (filePath: string): Promise => { + const ext = path.extname(filePath).toLowerCase(); + + try { + switch (ext) { + case '.pdf': + return await parsePdf(filePath); + case '.docx': + case '.doc': + return await parseWord(filePath); + case '.txt': + case '.text': + return fs.readFileSync(filePath, 'utf-8'); + default: + throw new Error(`Unsupported file format: ${ext}`); + } + } catch (error: any) { + console.error(`Error parsing document ${filePath}:`, error); + throw new Error(`Failed to parse document: ${error.message}`); + } +}; + +const parsePdf = async (filePath: string): Promise => { + try { + const dataBuffer = fs.readFileSync(filePath); + const data = await pdfParse(dataBuffer); + return data.text; + } catch (error: any) { + console.error('Error parsing PDF:', error); + throw new Error('Failed to parse PDF document'); + } +}; + +const parseWord = async (filePath: string): Promise => { + try { + const result = await mammoth.extractRawText({ path: filePath }); + return result.value; + } catch (error: any) { + console.error('Error parsing Word document:', error); + throw new Error('Failed to parse Word document'); + } +}; + +const scoreResume = async (resumePath: string, jobDescription?: string): Promise<{ score: number; feedback: string }> => { + try { + const resumeText = await parseDocument(resumePath); + if (resumeText.trim() == '') { + throw new TypeError('Could not parse the resume file'); + } + const prompt = feedbackTemplate(resumeText, jobDescription || 'No job description provided.'); + + let feedback = FEEDBACK_ERROR_MESSAGE; + if (config.chatAi.turned_on()) { + // Get feedback from the AI + feedback = await chatWithAI(prompt, SYSTEM_TEMPLATE); + } + + // Extract the score from the feedback + const scoreMatch = feedback.match(/SCORE: (\d+)/); + const score = scoreMatch ? parseInt(scoreMatch[1]) : 0; + return { score, feedback }; + } catch (error: any) { + if (error instanceof TypeError) { + console.error('TypeError while scoring resume:', error); + throw error; + } else { + console.error('Unexpected error while scoring resume:', error); + throw new Error('Failed to score resume'); + } + } +}; + +const streamScoreResume = async ( + resumePath: string, + jobDescription: string | undefined, + onChunk: (chunk: string) => void +): Promise => { + try { + const resumeText = await parseDocument(resumePath); + if (resumeText.trim() == '') { + throw new TypeError('Could not parse the resume file'); + } + const prompt = feedbackTemplate(resumeText, jobDescription || 'No job description provided.'); + + let fullResponse = ''; + let finalScore = 0; + + if (config.chatAi.turned_on()) { + await streamChatWithAI( + prompt, + SYSTEM_TEMPLATE, + (chunk) => { + fullResponse += chunk; + onChunk(chunk); + + // Try to extract score from the accumulated response + const scoreMatch = fullResponse.match(/SCORE: (\d+)/); + if (scoreMatch) { + finalScore = parseInt(scoreMatch[1]); + } + } + ); + } else { + onChunk(FEEDBACK_ERROR_MESSAGE); + } + + return finalScore; + } catch (error: any) { + if (error instanceof TypeError) { + console.error('TypeError while streaming resume score:', error); + throw error; + } else { + console.error('Unexpected error while streaming resume score:', error); + throw new Error('Failed to stream resume score'); + } + } +}; + +export { scoreResume, streamScoreResume }; \ No newline at end of file diff --git a/nextstep-backend/src/tests/resources_service.test.ts b/nextstep-backend/src/tests/resources_images.test.ts similarity index 100% rename from nextstep-backend/src/tests/resources_service.test.ts rename to nextstep-backend/src/tests/resources_images.test.ts diff --git a/nextstep-backend/src/tests/resources_resumes.test.ts b/nextstep-backend/src/tests/resources_resumes.test.ts new file mode 100644 index 0000000..6af14f4 --- /dev/null +++ b/nextstep-backend/src/tests/resources_resumes.test.ts @@ -0,0 +1,193 @@ +import request from 'supertest'; +import express from 'express'; +import multer from 'multer'; +import path from 'path'; +import fs from 'fs'; +import { config } from '../config/config'; +import resumeRoutes from '../routes/resume_routes'; +import { scoreResume } from '../services/resume_service'; +import resourcesRoutes from '../routes/resources_routes'; +import { authenticateToken } from '../middleware/auth'; + +// Mock the resume service +jest.mock('../services/resume_service'); + +// Mock the authentication middleware +jest.mock('../middleware/auth', () => ({ + authenticateToken: jest.fn((req, res, next) => next()) +})); + +describe('Resume API Tests', () => { + let app: express.Application; + const testResumePath = path.join(process.cwd(), 'test-resume.pdf'); + const testResumeContent = 'Test resume content'; + const testJobDescription = 'Software Engineer with 5 years of experience'; + const testToken = 'test-token'; + + beforeAll(() => { + // Create the resumes directory if it doesn't exist + const resumesDir = path.join(process.cwd(), config.resources.resumesDirectoryPath()); + if (!fs.existsSync(resumesDir)) { + fs.mkdirSync(resumesDir, { recursive: true }); + } + + // Create a test resume file + fs.writeFileSync(testResumePath, testResumeContent); + }); + + afterAll(() => { + // Clean up test files + if (fs.existsSync(testResumePath)) { + fs.unlinkSync(testResumePath); + } + }); + + beforeEach(() => { + app = express(); + app.use(express.json()); + app.use('/resource', resourcesRoutes); + app.use('/resume', resumeRoutes); + }); + + describe('GET /resume/score/:filename', () => { + it('should return a score for a valid resume', async () => { + // First upload a resume + const uploadResponse = await request(app) + .post('/resource/resume') + .set('Authorization', `Bearer ${testToken}`) + .attach('file', testResumePath); + + const filename = uploadResponse.text; + + // Mock the scoreResume function + (scoreResume as jest.Mock).mockResolvedValue({ score: 85 }); + + const response = await request(app) + .get(`/resume/score/${filename}?jobDescription=${encodeURIComponent(testJobDescription)}`) + .set('Authorization', `Bearer ${testToken}`) + .expect(200); + + expect(response.body).toHaveProperty('score'); + expect(typeof response.body.score).toBe('number'); + expect(scoreResume).toHaveBeenCalledWith( + expect.stringContaining(filename), + testJobDescription + ); + + // Clean up the uploaded file + const uploadedFilePath = path.join(config.resources.resumesDirectoryPath(), filename); + if (fs.existsSync(uploadedFilePath)) { + fs.unlinkSync(uploadedFilePath); + } + }); + + it('should return 404 for non-existent resume', async () => { + const response = await request(app) + .get('/resume/score/nonexistent.pdf') + .set('Authorization', `Bearer ${testToken}`) + .expect(404); + + expect(response.text).toBe('Resume not found'); + }); + + it('should handle errors gracefully', async () => { + // First upload a resume + const uploadResponse = await request(app) + .post('/resource/resume') + .set('Authorization', `Bearer ${testToken}`) + .attach('file', testResumePath); + + const filename = uploadResponse.text; + + // Mock the scoreResume function to throw an error + (scoreResume as jest.Mock).mockRejectedValue(new Error('Scoring failed')); + + const response = await request(app) + .get(`/resume/score/${filename}?jobDescription=${encodeURIComponent(testJobDescription)}`) + .set('Authorization', `Bearer ${testToken}`) + .expect(500); + + expect(response.body).toHaveProperty('message'); + expect(response.body.message).toBe('Scoring failed'); + + // Clean up the uploaded file + const uploadedFilePath = path.join(config.resources.resumesDirectoryPath(), filename); + if (fs.existsSync(uploadedFilePath)) { + fs.unlinkSync(uploadedFilePath); + } + }); + }); + + describe('POST /resource/resume', () => { + it('should upload a resume successfully', async () => { + const response = await request(app) + .post('/resource/resume') + .set('Authorization', `Bearer ${testToken}`) + .attach('file', testResumePath) + .expect(201); + + expect(response.text).toBeDefined(); + expect(response.text).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.pdf$/); + + // Clean up the uploaded file + const uploadedFilePath = path.join(config.resources.resumesDirectoryPath(), response.text); + if (fs.existsSync(uploadedFilePath)) { + fs.unlinkSync(uploadedFilePath); + } + }); + + it('should validate file type', async () => { + const response = await request(app) + .post('/resource/resume') + .set('Authorization', `Bearer ${testToken}`) + .attach('file', Buffer.from('invalid content'), { filename: 'test.png' }) + .expect(400); + + expect(response.text).toBe('Invalid file type. Only PDF, DOC, DOCX and TXT/TEXT files are allowed.'); + }); + + it('should handle missing file', async () => { + const response = await request(app) + .post('/resource/resume') + .set('Authorization', `Bearer ${testToken}`) + .expect(400); + + expect(response.text).toBe('No file uploaded.'); + }); + }); + + describe('GET /resource/resume/:filename', () => { + it('should download a resume successfully', async () => { + // First upload a resume + const uploadResponse = await request(app) + .post('/resource/resume') + .set('Authorization', `Bearer ${testToken}`) + .attach('file', testResumePath); + + const filename = uploadResponse.text; + + // Then try to download it + const response = await request(app) + .get(`/resource/resume/${filename}`) + .set('Authorization', `Bearer ${testToken}`) + .expect(200); + + expect(response.body).toBeDefined(); + + // Clean up the uploaded file + const uploadedFilePath = path.join(config.resources.resumesDirectoryPath(), filename); + if (fs.existsSync(uploadedFilePath)) { + fs.unlinkSync(uploadedFilePath); + } + }); + + it('should return 404 for non-existent resume', async () => { + const response = await request(app) + .get('/resource/resume/nonexistent.pdf') + .set('Authorization', `Bearer ${testToken}`) + .expect(404); + + expect(response.text).toBe('Resume not found'); + }); + }); +}); \ No newline at end of file diff --git a/nextstep-frontend/package.json b/nextstep-frontend/package.json index e2f4d35..a2838ff 100644 --- a/nextstep-frontend/package.json +++ b/nextstep-frontend/package.json @@ -25,7 +25,9 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-froala-wysiwyg": "^4.5.0", + "react-markdown": "^10.1.0", "react-router-dom": "^7.1.5", + "remark-gfm": "^4.0.1", "socket.io-client": "^4.8.1" }, "devDependencies": { diff --git a/nextstep-frontend/src/App.css b/nextstep-frontend/src/App.css index 7a9fb29..683a2cf 100644 --- a/nextstep-frontend/src/App.css +++ b/nextstep-frontend/src/App.css @@ -9,9 +9,13 @@ } #root { - max-width: 1280px; + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + height: 100vh; + width: 100%; margin: 0 auto; - padding: 2rem; text-align: center; } diff --git a/nextstep-frontend/src/App.tsx b/nextstep-frontend/src/App.tsx index 7d9e2f6..58f988e 100644 --- a/nextstep-frontend/src/App.tsx +++ b/nextstep-frontend/src/App.tsx @@ -10,21 +10,24 @@ import RequireAuth from './hoc/RequireAuth'; import NewPost from './pages/NewPost'; import PostDetails from './pages/PostDetails'; import Chat from './pages/Chat'; - +import Resume from './pages/Resume'; +import TopBar from './components/TopBar'; +import Layout from './components/Layout'; const App: React.FC = () => { return ( <> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> diff --git a/nextstep-frontend/src/components/Footer.css b/nextstep-frontend/src/components/Footer.css index db365e7..404eb5f 100644 --- a/nextstep-frontend/src/components/Footer.css +++ b/nextstep-frontend/src/components/Footer.css @@ -4,7 +4,5 @@ padding: 10px 20px; text-align: center; width: 100%; - position: absolute; - bottom: 0; justify-self: anchor-center; } \ No newline at end of file diff --git a/nextstep-frontend/src/components/Layout.css b/nextstep-frontend/src/components/Layout.css new file mode 100644 index 0000000..1eb91d5 --- /dev/null +++ b/nextstep-frontend/src/components/Layout.css @@ -0,0 +1,5 @@ +.layout-container { + height: 100%; + padding-top: 10vh; + overflow-y: auto; +} \ No newline at end of file diff --git a/nextstep-frontend/src/components/Layout.tsx b/nextstep-frontend/src/components/Layout.tsx new file mode 100644 index 0000000..7835387 --- /dev/null +++ b/nextstep-frontend/src/components/Layout.tsx @@ -0,0 +1,20 @@ +import { Container } from '@mui/material'; +import React from 'react'; +import './Layout.css'; + + +const Layout: React.FC<{ children: React.ReactNode }> = (props: any) => { + return ( + + { props.children } + + ); +}; + +export default Layout; \ No newline at end of file diff --git a/nextstep-frontend/src/components/TopBar.tsx b/nextstep-frontend/src/components/TopBar.tsx index 23e1333..2a3350d 100644 --- a/nextstep-frontend/src/components/TopBar.tsx +++ b/nextstep-frontend/src/components/TopBar.tsx @@ -1,7 +1,7 @@ import React, { useRef } from 'react'; import { useNavigate } from 'react-router-dom'; import { AppBar, Toolbar, IconButton, Tooltip, Box } from '@mui/material'; -import { Home, Person, Message, Logout } from '@mui/icons-material'; +import { Home, Person, Message, Logout, DocumentScannerTwoTone } from '@mui/icons-material'; import {getUserAuth, removeUserAuth} from "../handlers/userAuth.ts"; import api from "../serverApi.ts"; @@ -20,7 +20,7 @@ const TopBar: React.FC = () => { }; return ( - + navigate('/dashboard')} sx={{ mx: 1 }}> @@ -37,6 +37,11 @@ const TopBar: React.FC = () => { + + navigate('/resume')} sx={{ mx: 1 }}> + + + diff --git a/nextstep-frontend/src/index.css b/nextstep-frontend/src/index.css index 6119ad9..2514a69 100644 --- a/nextstep-frontend/src/index.css +++ b/nextstep-frontend/src/index.css @@ -1,3 +1,357 @@ +/* ------------------------------------ Normalize.css --------------------------------- */ + +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + + html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} + +/* ------------------------------------ Normalize.css End --------------------------------- */ + :root { font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; line-height: 1.5; diff --git a/nextstep-frontend/src/pages/Chat.css b/nextstep-frontend/src/pages/Chat.css index 26853f5..977cb5d 100644 --- a/nextstep-frontend/src/pages/Chat.css +++ b/nextstep-frontend/src/pages/Chat.css @@ -1,14 +1,16 @@ .chat-container { - margin-top: 70px; display: flex; flex-direction: column; - height: 100%; padding: 20px; background-color: #f9f9f9; border: 1px solid #ccc; border-radius: 8px; } +.online-users { + overflow-y: auto; +} + .message-input { display: flex; margin-bottom: 10px; diff --git a/nextstep-frontend/src/pages/Chat.tsx b/nextstep-frontend/src/pages/Chat.tsx index 15ceb7b..c35e9b2 100644 --- a/nextstep-frontend/src/pages/Chat.tsx +++ b/nextstep-frontend/src/pages/Chat.tsx @@ -6,7 +6,6 @@ import { LoginResponse } from '../models/LoginResponse'; import DividedList from '../components/DividedList'; import { Room } from '../models/Room'; import axios from 'axios'; -import TopBar from '../components/TopBar'; const Chat: React.FC = () => { const [messageContent, setMessageContent] = useState(''); @@ -81,7 +80,6 @@ const Chat: React.FC = () => { return (
-
{ }; return ( - - - - - - - setFilterByUser(!filterByUser)} - color="primary" - /> - } - label="Show My Posts" - /> - - - {isLoading ? ( - - - - ) : error ? ( - {error} - ) : ( - <> - - {posts.map((post) => ( - - navigate(`/post/${post.id}`)}> - - - - {post.ownerUsername || post.owner} - - - {post.title} - - - - - - - handleLikePost(e, post.id)} - color={isLikedByUser[post.id] ? 'primary' : 'default'} - > - - - - - - - - - - {post.owner === auth.userId && ( - handleOpenDialog(e, post.id)}> - + + + + + + + setFilterByUser(!filterByUser)} + color="primary" + /> + } + label="Show My Posts" + /> + + + {isLoading ? ( + + + + ) : error ? ( + {error} + ) : ( + <> + + {posts.map((post) => ( + + navigate(`/post/${post.id}`)}> + + + + {post.ownerUsername || post.owner} + + + {post.title} + + + + + + + handleLikePost(e, post.id)} + color={isLikedByUser[post.id] ? 'primary' : 'default'} + > + + + - )} - - - - ))} - - - )} + + + + + + {post.owner === auth.userId && ( + handleOpenDialog(e, post.id)}> + + + )} + + + + ))} + + + )} + + + + {`Page ${currentPage} of ${totalPages}`} + + - - - {`Page ${currentPage} of ${totalPages}`} - - - + + - - - {"Are you sure you want to delete this post?"} - - - This action cannot be undone. - - - - - - - - + ); }; diff --git a/nextstep-frontend/src/pages/Login.css b/nextstep-frontend/src/pages/Login.css index 77b40e2..ebf6be9 100644 --- a/nextstep-frontend/src/pages/Login.css +++ b/nextstep-frontend/src/pages/Login.css @@ -1,10 +1,3 @@ -body { - margin: 0; - font-family: Arial, sans-serif; - background-color: var(--color-2); - display: flex; -} - .login-container { display: flex; justify-content: center; diff --git a/nextstep-frontend/src/pages/Login.tsx b/nextstep-frontend/src/pages/Login.tsx index c37c95d..fec6550 100644 --- a/nextstep-frontend/src/pages/Login.tsx +++ b/nextstep-frontend/src/pages/Login.tsx @@ -67,7 +67,7 @@ const Login: React.FC = () => { return (
- + Next Step diff --git a/nextstep-frontend/src/pages/NewPost.tsx b/nextstep-frontend/src/pages/NewPost.tsx index 1f773ef..1e50e14 100644 --- a/nextstep-frontend/src/pages/NewPost.tsx +++ b/nextstep-frontend/src/pages/NewPost.tsx @@ -6,7 +6,6 @@ import FroalaEditor from 'react-froala-wysiwyg'; import 'froala-editor/css/froala_style.min.css'; import 'froala-editor/css/froala_editor.pkgd.min.css'; import 'froala-editor/js/plugins/image.min.js'; -import TopBar from '../components/TopBar'; import api from "../serverApi.ts"; import { getUserAuth } from '../handlers/userAuth.ts'; @@ -59,7 +58,6 @@ const NewPost: React.FC = () => { return ( - Create New Post diff --git a/nextstep-frontend/src/pages/PostDetails.tsx b/nextstep-frontend/src/pages/PostDetails.tsx index 82d6517..3a38d2f 100644 --- a/nextstep-frontend/src/pages/PostDetails.tsx +++ b/nextstep-frontend/src/pages/PostDetails.tsx @@ -8,7 +8,6 @@ import 'froala-editor/css/froala_editor.pkgd.min.css'; import 'froala-editor/js/plugins/image.min.js'; import { Post as PostModel } from '../models/Post'; import { Comment } from '../models/Comment'; -import TopBar from '../components/TopBar'; import { getUserAuth } from "../handlers/userAuth.ts"; import api from "../serverApi.ts"; import defaultProfileImage from '../assets/defaultProfileImage.jpg'; @@ -196,7 +195,6 @@ const PostDetails: React.FC = () => { return ( - navigate('/dashboard')}> diff --git a/nextstep-frontend/src/pages/Profile.tsx b/nextstep-frontend/src/pages/Profile.tsx index 2a0217d..533cf4b 100644 --- a/nextstep-frontend/src/pages/Profile.tsx +++ b/nextstep-frontend/src/pages/Profile.tsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Container, Typography, Box, Button, Alert, TextField, Collapse } from '@mui/material'; import { ExpandMore, ExpandLess } from '@mui/icons-material'; -import TopBar from '../components/TopBar'; import {getUserAuth} from "../handlers/userAuth.ts"; import api from "../serverApi.ts"; import {UserProfile} from "../models/UserProfile.ts"; @@ -105,9 +104,8 @@ const Profile: React.FC = () => { }; return ( - - - + + Profile diff --git a/nextstep-frontend/src/pages/Register.css b/nextstep-frontend/src/pages/Register.css deleted file mode 100644 index 307a063..0000000 --- a/nextstep-frontend/src/pages/Register.css +++ /dev/null @@ -1,10 +0,0 @@ -body { - margin: 0; - font-family: Arial, sans-serif; - background-color: var(--color-2); - display: flex; - justify-content: center; - align-items: center; - text-align: center; - overflow: hidden; -} diff --git a/nextstep-frontend/src/pages/Register.tsx b/nextstep-frontend/src/pages/Register.tsx index 5c07b43..7ac8cf0 100644 --- a/nextstep-frontend/src/pages/Register.tsx +++ b/nextstep-frontend/src/pages/Register.tsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import axios from 'axios'; import { Container, TextField, Button, Typography, Box, Paper, Link, Alert } from '@mui/material'; import { Link as RouterLink, useNavigate } from 'react-router-dom'; -import './Register.css'; import { config } from '../config'; const Register: React.FC = () => { @@ -41,7 +40,7 @@ const Register: React.FC = () => { return (
- + Next Step diff --git a/nextstep-frontend/src/pages/Resume.tsx b/nextstep-frontend/src/pages/Resume.tsx new file mode 100644 index 0000000..c51db44 --- /dev/null +++ b/nextstep-frontend/src/pages/Resume.tsx @@ -0,0 +1,256 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { Box, Button, CircularProgress, Typography, TextField } from '@mui/material'; +import { styled } from '@mui/material/styles'; +import { config } from '../config'; +import api from '../serverApi'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; + +const UploadBox = styled(Box)(({ theme }) => ({ + border: '2px dashed #ccc', + borderRadius: '8px', + padding: '20px', + textAlign: 'center', + cursor: 'pointer', + '&:hover': { + borderColor: theme.palette.primary.main, + }, +})); + +const ScoreGauge = styled(Box)<{ score: number }>(({ theme }) => ({ + width: '200px', + height: '200px', + borderRadius: '50%', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + background: `conic-gradient( + ${theme.palette.error.main} 0% 33%, + ${theme.palette.warning.main} 33% 66%, + ${theme.palette.success.main} 66% 100% + )`, + position: 'relative', + '&::before': { + content: '""', + position: 'absolute', + width: '180px', + height: '180px', + borderRadius: '50%', + background: theme.palette.background.paper, + }, +})); + +const ScoreText = styled(Typography)({ + position: 'absolute', + fontSize: '2.5rem', + fontWeight: 'bold', +}); + +const FeedbackContainer = styled(Box)(({ theme }) => ({ + maxHeight: '60vh', + overflowY: 'auto', + padding: theme.spacing(2), + backgroundColor: theme.palette.background.paper, + borderRadius: theme.shape.borderRadius, + textAlign: 'left', + '& pre': { + backgroundColor: theme.palette.grey[100], + padding: theme.spacing(1), + borderRadius: theme.shape.borderRadius, + overflowX: 'auto', + }, + '& table': { + borderCollapse: 'collapse', + width: '100%', + marginBottom: theme.spacing(2), + }, + '& th, & td': { + border: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(1), + textAlign: 'left', + }, + '& th': { + backgroundColor: theme.palette.grey[100], + }, + '& h1, & h2, & h3, & h4, & h5, & h6': { + textAlign: 'left', + }, + '& p': { + textAlign: 'left', + }, +})); + +const Resume: React.FC = () => { + const [file, setFile] = useState(null); + const [jobDescription, setJobDescription] = useState(''); + const [feedback, setFeedback] = useState(''); + const [score, setScore] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const fileInputRef = useRef(null); + const feedbackEndRef = useRef(null); + + // Auto-scroll to bottom when feedback updates + useEffect(() => { + if (feedbackEndRef.current) { + feedbackEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + }, [feedback]); + + const uploadResume = async (formData: FormData) => { + // First, upload the resume file to get the filename + const response = await api.post('/resource/resume', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + return response.data; + }; + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + setFile(event.target.files[0]); + setError(''); + } + }; + + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleSubmit = async () => { + if (!file) { + setError('Please select a file'); + return; + } + + setLoading(true); + setFeedback(''); + setScore(null); + setError(''); + + try { + const formData = new FormData(); + formData.append('file', file); + + // First upload the file + const filename = await uploadResume(formData); + + // Then set up the EventSource for streaming with authentication + const token = localStorage.getItem(config.localStorageKeys.userAuth) + ? JSON.parse(localStorage.getItem(config.localStorageKeys.userAuth)!).accessToken + : ''; + + const eventSource = new EventSource( + `${config.app.backend_url()}/resume/streamScore/${filename}?jobDescription=${encodeURIComponent(jobDescription)}&accessToken=${encodeURIComponent(token)}` + ); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + if (data.done) { + // Final score received + setScore(data.score); + eventSource.close(); + setLoading(false); + } else if (data.chunk) { + // Streamed chunk received + setFeedback(prev => prev + data.chunk); + } + } catch (e) { + console.error('Error parsing event data:', e); + } + }; + + eventSource.onerror = (error) => { + console.error('EventSource failed:', error); + setError('Failed to analyze resume'); + eventSource.close(); + setLoading(false); + }; + + } catch (err) { + setError(err instanceof Error ? err.message : 'An error occurred'); + setLoading(false); + } + }; + + return ( + + + Resume Score Analyzer + + + + setJobDescription(e.target.value)} + sx={{ mb: 2 }} + /> + + + + + {file ? ( + {file.name} + ) : ( + Click to upload your resume + )} + + + {error && ( + + {error} + + )} + + + + + {loading && } + + {feedback && ( + + + Analysis Feedback: + + + + {feedback} + +
+ + + )} + + {score !== null && ( + + + {score} + + + )} + + ); +}; + +export default Resume; \ No newline at end of file