diff --git a/nextstep-backend/package.json b/nextstep-backend/package.json index 9d37754..bd313f5 100644 --- a/nextstep-backend/package.json +++ b/nextstep-backend/package.json @@ -13,7 +13,7 @@ "type": "git", "url": "git+https://github.com/NextStepFinalProject/NextStep.git" }, - "author": "Mevorah Berrebi & Tal Jacob & Lina Elman & Liav Tibi", + "author": "Tal Jacob & Lina Elman & Liav Tibi", "license": "ISC", "bugs": { "url": "https://github.com/NextStepFinalProject/NextStep/issues" @@ -39,6 +39,7 @@ "jest-junit": "^16.0.0", "js-yaml": "^4.1.0", "jsonwebtoken": "^9.0.2", + "linkedin-jobs-api": "^1.0.6", "mammoth": "^1.9.0", "mongoose": "^8.8.2", "multer": "^1.4.5-lts.1", diff --git a/nextstep-backend/src/app.ts b/nextstep-backend/src/app.ts index fb7f02a..6cfd964 100644 --- a/nextstep-backend/src/app.ts +++ b/nextstep-backend/src/app.ts @@ -16,6 +16,7 @@ import loadOpenApiFile from "./openapi/openapi_loader"; import resource_routes from './routes/resources_routes'; import resume_routes from './routes/resume_routes'; import githubRoutes from './routes/github_routes'; +import linkedinJobsRoutes from './routes/linkedin_jobs_routes'; const specs = swaggerJsdoc(options); @@ -76,5 +77,6 @@ app.use('/resource', resource_routes); app.use('/room', roomsRoutes); app.use('/resume', resume_routes); app.use('/github', githubRoutes); +app.use('/linkedin-jobs', linkedinJobsRoutes); export { app, corsOptions }; diff --git a/nextstep-backend/src/controllers/linkedin_jobs_controller.ts b/nextstep-backend/src/controllers/linkedin_jobs_controller.ts new file mode 100644 index 0000000..38550fa --- /dev/null +++ b/nextstep-backend/src/controllers/linkedin_jobs_controller.ts @@ -0,0 +1,71 @@ +import { Request, Response } from 'express'; +import linkedIn from 'linkedin-jobs-api'; +import { getCompanyLogo } from '../services/company_logo_service'; + +export const getJobsBySkillsAndRole = async (req: Request, res: Response) => { + try { + const skillsParam = String(req.query.skills || '').trim(); + const role = String(req.query.role || '').trim(); + const location = String(req.query.location || 'Israel').trim(); + const dateSincePosted = String(req.query.dateSincePosted || 'past week').trim(); + const jobType = String(req.query.jobType || 'full time').trim(); + const experienceLevel = String(req.query.experienceLevel || 'entry level').trim(); + + if (!skillsParam || !role) { + return res.status(400).json({ error: 'Skills and role are required' }); + } + + // Split skills by comma and trim whitespace + const skillsArray = skillsParam + .split(',') + .map(skill => skill.trim()) + .filter(Boolean); + + // Construct keyword by combining role and skills + const keyword = `${role} ${skillsArray.join(' ')}`.trim(); + + const queryOptions = { + keyword, + location, + dateSincePosted, + jobType, + experienceLevel, + limit: '10', + page: '0', + }; + + const jobs = await linkedIn.query(queryOptions); + + // Fetch company logos for each job + const jobsWithLogos = await Promise.all( + jobs.map(async (job: any) => { + const companyLogo = await getCompanyLogo(job.company); + return { + ...job, + companyLogo, + position: job.position // Add position field + }; + }) + ); + + res.status(200).json(jobsWithLogos); + } catch (error: any) { + console.error('Error fetching jobs from LinkedIn Jobs API:', error.message); + res.status(500).json({ error: 'Failed to fetch jobs from LinkedIn Jobs API' }); + } +}; + +export const viewJobDetails = async (req: Request, res: Response) => { + try { + const jobId = req.params.id; + if (!jobId) { + return res.status(400).json({ error: 'Job ID is required' }); + } + + const jobDetails = await linkedIn.query({ keyword: jobId, limit: '1' }); + res.status(200).json(jobDetails); + } catch (error: any) { + console.error('Error fetching job details:', error.message); + res.status(500).json({ error: 'Failed to fetch job details' }); + } +}; diff --git a/nextstep-backend/src/openapi/swagger.yaml b/nextstep-backend/src/openapi/swagger.yaml index f724bcd..4ba9fb1 100644 --- a/nextstep-backend/src/openapi/swagger.yaml +++ b/nextstep-backend/src/openapi/swagger.yaml @@ -19,6 +19,8 @@ tags: description: Operations related to chat rooms - name: Resume description: Operations related to resume ATS scoring + - name: LinkedIn Jobs + description: Operations related to LinkedIn job postings paths: /post: @@ -1114,6 +1116,135 @@ paths: '400': description: Bad Request + /linkedin-jobs/jobs: + get: + tags: + - LinkedIn Jobs + summary: Retrieve jobs from LinkedIn based on skills and role + parameters: + - name: skills + in: query + required: true + schema: + type: string + description: Comma-separated list of skills (maximum 3) + - name: role + in: query + required: true + schema: + type: string + description: Desired role + - name: location + in: query + required: false + schema: + type: string + description: Job location + - name: dateSincePosted + in: query + required: false + schema: + type: string + description: Date range for job postings (e.g., "past day", "past week", "past month") + - name: jobType + in: query + required: false + schema: + type: string + description: Type of job (e.g., "full time", "part time", "contract") + - name: experienceLevel + in: query + required: false + schema: + type: string + description: Experience level (e.g., "entry level", "mid level", "senior level", "all") + responses: + '200': + description: List of jobs retrieved successfully + content: + application/json: + schema: + type: array + items: + type: object + properties: + position: + type: string + description: Job position + company: + type: string + description: Company name + location: + type: string + description: Job location + jobUrl: + type: string + description: Job posting URL + companyLogo: + type: string + description: URL of the company logo + date: + type: string + description: Date the job was posted + salary: + type: string + description: Salary information + '400': + description: Bad request - Missing skills or role + '500': + description: Internal server error + + /linkedin-jobs/jobs/{id}: + get: + tags: + - LinkedIn Jobs + summary: Retrieve details of a specific job by ID + parameters: + - name: id + in: path + required: true + schema: + type: string + description: Job ID + responses: + '200': + description: Job details retrieved successfully + content: + application/json: + schema: + type: object + properties: + position: + type: string + description: Job position + company: + type: string + description: Company name + location: + type: string + description: Job location + description: + type: string + description: Detailed job description + jobUrl: + type: string + description: Job posting URL + companyLogo: + type: string + description: URL of the company logo + date: + type: string + description: Date the job was posted + salary: + type: string + description: Salary information + '400': + description: Bad request - Missing job ID + '404': + description: Job not found + '500': + description: Internal server error + components: schemas: Post: diff --git a/nextstep-backend/src/routes/linkedin_jobs_routes.ts b/nextstep-backend/src/routes/linkedin_jobs_routes.ts new file mode 100644 index 0000000..625a6b6 --- /dev/null +++ b/nextstep-backend/src/routes/linkedin_jobs_routes.ts @@ -0,0 +1,9 @@ +import express from 'express'; +import { getJobsBySkillsAndRole, viewJobDetails } from '../controllers/linkedin_jobs_controller'; + +const router = express.Router(); + +router.get('/jobs', getJobsBySkillsAndRole); +router.get('/jobs/:id', viewJobDetails); + +export default router; diff --git a/nextstep-backend/src/services/company_logo_service.ts b/nextstep-backend/src/services/company_logo_service.ts new file mode 100644 index 0000000..0fbef50 --- /dev/null +++ b/nextstep-backend/src/services/company_logo_service.ts @@ -0,0 +1,15 @@ +import axios from 'axios'; + +export const getCompanyLogo = async (companyName: string): Promise => { + try { + const response = await axios.get(`https://logo.clearbit.com/${encodeURIComponent(companyName)}.com`); + return response.status === 200 ? response.config.url ?? null : null; + } catch (error) { + if (error instanceof Error) { + console.error(`Failed to fetch logo for company: ${companyName}`, error.message); + } else { + console.error(`Failed to fetch logo for company: ${companyName}`, error); + } + return null; + } +}; diff --git a/nextstep-backend/src/types/linkedin-jobs-api.d.ts b/nextstep-backend/src/types/linkedin-jobs-api.d.ts new file mode 100644 index 0000000..186782d --- /dev/null +++ b/nextstep-backend/src/types/linkedin-jobs-api.d.ts @@ -0,0 +1,26 @@ +declare module 'linkedin-jobs-api' { + interface Job { + position: string; + company: string; + location: string; + date: string; + // Add other relevant fields as needed + } + + interface QueryOptions { + keyword: string; + location?: string; + dateSincePosted?: string; + jobType?: string; + remoteFilter?: string; + salary?: string; + experienceLevel?: string; + limit?: string; + page?: string; + } + + function query(options: QueryOptions): Promise; + + export = { query }; +} + diff --git a/nextstep-backend/tsconfig.json b/nextstep-backend/tsconfig.json index 91d9de4..c29761f 100644 --- a/nextstep-backend/tsconfig.json +++ b/nextstep-backend/tsconfig.json @@ -12,7 +12,8 @@ "paths": { "*": ["node_modules/*"], "types/*": ["src/types/*"] - } + }, + "typeRoots": ["src/types", "./node_modules/@types"] }, "include": ["src/**/*.ts", "index.ts", "src/*.ts", "src/**/*"], "exclude": ["node_modules", "dist"] diff --git a/nextstep-frontend/src/App.css b/nextstep-frontend/src/App.css index 683a2cf..77db3ae 100644 --- a/nextstep-frontend/src/App.css +++ b/nextstep-frontend/src/App.css @@ -54,3 +54,30 @@ .read-the-docs { color: #888; } + +.job-card { + padding: 16px; + border: 1px solid #ddd; + border-radius: 8px; + height: 200px; /* Fixed height for all cards */ + display: flex; + flex-direction: column; + justify-content: space-between; /* Align content and button */ + overflow-y: auto; /* Enable vertical scrolling for overflow */ +} + +.job-card img { + display: inline-block; + margin: 0; + max-width: 20px; + height: 20px; +} + +.job-card .company-name { + font-weight: bold; /* Make company name bold */ +} + +.job-card .location { + margin-top: 8px; /* Add separation between company name and location */ + display: block; /* Ensure it appears on a new line */ +} diff --git a/nextstep-frontend/src/components/LinkedInIntegration.tsx b/nextstep-frontend/src/components/LinkedInIntegration.tsx new file mode 100644 index 0000000..a5e0929 --- /dev/null +++ b/nextstep-frontend/src/components/LinkedInIntegration.tsx @@ -0,0 +1,301 @@ +import React, { useState } from 'react'; +import { Box, Typography, Button, Grid, CircularProgress, IconButton, TextField, MenuItem, Select, FormControl, InputLabel, Dialog, DialogTitle, DialogContent, DialogActions, Chip, Stack } from '@mui/material'; +import { ExpandLess, LinkedIn, Settings } from '@mui/icons-material'; + +interface Job { + position: string; + company: string; + location: string; + jobUrl?: string; + companyLogo?: string; + date?: string; + salary?: string; +} + +interface LinkedInIntegrationProps { + jobs: Job[]; + loadingJobs: boolean; + fetchJobs: (settings: LinkedInSettings) => Promise; + showJobRecommendations: boolean; + toggleJobRecommendations: () => void; + skills: string[]; + selectedRole: string; +} + +interface LinkedInSettings { + location: string; + dateSincePosted: string; + jobType: string; + experienceLevel: string; + skills: string[]; +} + +const LinkedInIntegration: React.FC = ({ + jobs, + loadingJobs, + fetchJobs, + showJobRecommendations, + toggleJobRecommendations, + skills, + selectedRole, +}) => { + const [settings, setSettings] = useState({ + location: 'Israel', + dateSincePosted: 'past month', + jobType: 'full time', + experienceLevel: 'all', + skills: skills.slice(0, 3), // Limit to first 3 skills + }); + + const [selectedJob, setSelectedJob] = useState(null); + const [jobDetails, setJobDetails] = useState(null); + + const handleSettingChange = (key: keyof LinkedInSettings, value: string) => { + setSettings((prev) => ({ ...prev, [key]: value })); + }; + + const handleFetchJobs = () => { + fetchJobs(settings); + }; + + const handleViewJob = (job: Job) => { + setSelectedJob(job); + setJobDetails(job); + }; + + const handleCloseDialog = () => { + setSelectedJob(null); + setJobDetails(null); + }; + + return ( + + + + + + Job Recommendations + + + + {showJobRecommendations ? : } + + + {showJobRecommendations && ( + <> + + + + + Selected Role: {selectedRole || 'None'} + + + + handleSettingChange('location', e.target.value)} + fullWidth + /> + + + + Date Since Posted + + + + + + Job Type + + + + + + Experience Level + + + + + + + + Skills Filter: + + + {settings.skills.map((skill, index) => ( + { + const updatedSkills = [...settings.skills]; + updatedSkills.splice(index, 1); + setSettings((prev) => ({ ...prev, skills: updatedSkills })); + }} + sx={{ backgroundColor: '#e3f2fd', color: '#0d47a1' }} + /> + ))} + + { + const input = e.target as HTMLInputElement; + if (e.key === 'Enter' && input.value.trim()) { + const newSkill = input.value.trim(); + if (!settings.skills.includes(newSkill) && settings.skills.length < 3) { + setSettings((prev) => ({ + ...prev, + skills: [...prev.skills, newSkill], + })); + } + input.value = ''; + } + }} + placeholder={settings.skills.length >= 3 ? "Reached max of 3 skills" : "Type a skill and press Enter"} + disabled={settings.skills.length >= 3} // Disable input if 3 skills are already added + sx={{ mt: 1 }} + /> + +
+ + + + {jobs.length > 0 ? ( + + {jobs.map((job, index) => ( + + + + + {job.companyLogo && ( + {`${job.company} + )} + + {job.company} + + + {job.position.toLowerCase()} +
+ + {job.location} + +
+ +
+
+ ))} +
+ ) : ( + + No job recommendations found. Try adjusting your search settings. + + )} + + {/* Job Details Dialog */} + + + {selectedJob?.position} + + + {jobDetails ? ( + <> + + {jobDetails.companyLogo && ( + {`${jobDetails.company} + )} + {jobDetails.company} + + + {jobDetails.location} + + + ) : ( + Failed to load job details. + )} + + + + {selectedJob?.jobUrl && ( + + )} + + + + )} +
+ ); +}; + +export default LinkedInIntegration; diff --git a/nextstep-frontend/src/pages/MainDashboard.tsx b/nextstep-frontend/src/pages/MainDashboard.tsx index 9903df3..354e3cb 100644 --- a/nextstep-frontend/src/pages/MainDashboard.tsx +++ b/nextstep-frontend/src/pages/MainDashboard.tsx @@ -34,6 +34,7 @@ import { handleGitHubOAuth } from '../handlers/githubAuth'; import api from '../serverApi'; +import LinkedInIntegration from '../components/LinkedInIntegration'; const roles = [ 'Software Engineer', 'Frontend Developer', 'Backend Developer', @@ -65,6 +66,17 @@ const MainDashboard: React.FC = () => { const SKILL_DISPLAY_LIMIT = 6; const shouldShowToggle = skills.length > SKILL_DISPLAY_LIMIT; + // LinkedIn jobs state + const [jobs, setJobs] = useState<{ position: string; company: string; location: string; url: string, companyLogo?: string, jobUrl?: string }[]>([]); + const [loadingJobs, setLoadingJobs] = useState(false); // New state for loading jobs + + // Job Recommendations toggle + const [showJobRecommendations, setShowJobRecommendations] = useState(false); // New state for toggle + + const toggleJobRecommendations = () => { + setShowJobRecommendations(!showJobRecommendations); + }; + // Persist to localStorage useEffect(() => { localStorage.setItem('aboutMe', aboutMe); }, [aboutMe]); useEffect(() => { localStorage.setItem('skills', JSON.stringify(skills)); }, [skills]); @@ -150,11 +162,33 @@ const MainDashboard: React.FC = () => { } }; + // Fetch Linkedin Jobs + const fetchJobs = async (settings: { location: string; dateSincePosted: string; jobType: string; experienceLevel: string; skills?: string[] }) => { + setLoadingJobs(true); + try { + const response = await api.get('/linkedin-jobs/jobs', { + params: { + skills: (settings.skills || skills.slice(0, 3)).join(','), // Use updated skills or default to first three + role: selectedRole, + location: settings.location, + dateSincePosted: settings.dateSincePosted, + jobType: settings.jobType, + experienceLevel: settings.experienceLevel, + }, + }); + setJobs(response.data); + } catch (error) { + console.error('Error fetching jobs:', error); + } finally { + setLoadingJobs(false); + } + }; + return ( {/* Left Column */} - + {/* Adjusted width */} {/* About Me Section */} @@ -301,12 +335,11 @@ const MainDashboard: React.FC = () => { )} - {/* Right Column */} - + @@ -377,6 +410,17 @@ const MainDashboard: React.FC = () => { )} + + {/* Jobs Section */} +