diff --git a/.gitignore b/.gitignore index c2658d7..3ec544c 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 71a50d8..e9fcd58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,20 @@ FROM node:alpine WORKDIR /app + +# Copy package files COPY package*.json ./ -RUN npm install --omit=dev + +# Install all dependencies (including dev for Vite build) +RUN npm install + +# Copy source files COPY . . + +# Build React app +RUN npx vite build + +# Remove dev dependencies to slim down image (optional) +# RUN npm prune --production + EXPOSE 8081 CMD ["node", "api/server.js"] diff --git a/README.md b/README.md index 214dbe2..0c80c84 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,22 @@ Club's internal alumni directory ## Prerequisites - [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running +- **Firecrawl API key** (contact the dev team for this.) -## Getting Started +## Configuration + +Create a `.env` file in the project root: ```bash -# Clone the repo -git clone https://github.com/SCE-Development/sce-linkedin.git -cd sce-linkedin +DATABASE_HOST=127.0.0.1 +FIRECRAWL_API_KEY=fc-your-key-here +``` -# Start the dev environment (Express server + MongoDB) +## Getting Started + +```bash +# Start the dev environment (Express + MongoDB) docker compose -f docker-compose.dev.yml up --build + +# Access the app at http://localhost:8081 ``` diff --git a/api/models/Alumni.js b/api/models/Alumni.js index b4a607c..49cccb8 100644 --- a/api/models/Alumni.js +++ b/api/models/Alumni.js @@ -29,6 +29,10 @@ const ExperienceSchema = new Schema({ const AlumniSchema = new Schema( { + name: { + type: String, + required: true + }, userId: { type: Schema.Types.ObjectId, ref: 'User', @@ -61,6 +65,27 @@ const AlumniSchema = new Schema( type: String, default: '' }, + currentCompany: { + type: String, + default: '' + }, + currentJobTitle: { + type: String, + default: '' + }, + location: { + type: String, + default: '' + }, + enrichmentStatus: { + type: String, + enum: ['pending', 'completed', 'failed'], + default: null + }, + enrichmentJobId: { + type: String, + default: '' + }, experiences: { type: [ExperienceSchema], default: [] diff --git a/api/routes/Alumni.js b/api/routes/Alumni.js index 7e3d41e..066be6d 100644 --- a/api/routes/Alumni.js +++ b/api/routes/Alumni.js @@ -1,10 +1,21 @@ const express = require('express'); const Alumni = require('../models/Alumni'); +const { enrichAlumni, getEnrichedData } = require('../services/enrichmentService'); + +// Generate a random 24-character hex string (MongoDB ObjectId format) +function generateObjectId() { + const chars = '0123456789abcdef'; + let result = ''; + for (let i = 0; i < 24; i++) { + result += chars[Math.floor(Math.random() * chars.length)]; + } + return result; +} const router = express.Router(); // List all alumni profiles -router.get('/', async (req, res) => { +router.get('/alumni', async (req, res) => { try { const alumni = await Alumni.find(); res.json(alumni); @@ -14,7 +25,7 @@ router.get('/', async (req, res) => { }); // Get a single alumni profile by ID -router.get('/:id', async (req, res) => { +router.get('/alumni/:id', async (req, res) => { try { const alumni = await Alumni.findById(req.params.id); if (!alumni) return res.status(404).json({ error: 'Alumni not found' }); @@ -25,8 +36,18 @@ router.get('/:id', async (req, res) => { }); // Create a new alumni profile -router.post('/', async (req, res) => { +router.post('/alumni', async (req, res) => { try { + // Ensure name is provided + if (!req.body.name) { + return res.status(400).json({ error: 'Name is required for alumni record' }); + } + + // Auto-generate userId if not provided + if (!req.body.userId) { + req.body.userId = generateObjectId(); + } + const alumni = await new Alumni(req.body).save(); res.status(201).json(alumni); } catch (err) { @@ -34,8 +55,45 @@ router.post('/', async (req, res) => { } }); +// Start enrichment job (preview mode - doesn't save to DB) +router.post('/alumni/enrich', async (req, res) => { + try { + const { name, graduationYear } = req.body; + + if (!name) { + return res.status(400).json({ error: 'Name is required for enrichment' }); + } + + const alumni = { name, graduationYear }; + const result = await enrichAlumni(alumni); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + res.json({ jobId: result.jobId, status: 'pending' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Poll for enrichment job status +router.get('/alumni/enrich/:jobId', async (req, res) => { + try { + const result = await getEnrichedData(req.params.jobId); + + if (!result.success) { + return res.status(500).json({ error: result.error }); + } + + res.json(result); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + // Update an existing alumni profile -router.put('/:id', async (req, res) => { +router.put('/alumni/:id', async (req, res) => { try { const alumni = await Alumni.findByIdAndUpdate(req.params.id, req.body, { new: true, @@ -49,7 +107,7 @@ router.put('/:id', async (req, res) => { }); // Delete an alumni profile -router.delete('/:id', async (req, res) => { +router.delete('/alumni/:id', async (req, res) => { try { const alumni = await Alumni.findByIdAndDelete(req.params.id); if (!alumni) return res.status(404).json({ error: 'Alumni not found' }); diff --git a/api/server.js b/api/server.js index 8d67158..bffada5 100644 --- a/api/server.js +++ b/api/server.js @@ -1,13 +1,28 @@ +require('dotenv').config(); + const express = require('express'); const mongoose = require('mongoose'); const alumniRouter = require('./routes/Alumni'); +const path = require('path'); const app = express(); const PORT = 8081; app.use(express.json()); + +// Serve React build from dist/ +app.use(express.static('dist')); + +// API routes app.use('/api', alumniRouter); +// Fallback to index.html for React - must come after static and API routes +app.use((req, res) => { + if (!req.path.startsWith('/api')) { + res.sendFile(path.join(__dirname, 'dist', 'index.html')); + } +}); + const dbHost = process.env.DATABASE_HOST || '127.0.0.1'; mongoose .connect(`mongodb://${dbHost}:27017/sce_linkedin`) diff --git a/api/services/enrichmentService.js b/api/services/enrichmentService.js new file mode 100644 index 0000000..ac17dcd --- /dev/null +++ b/api/services/enrichmentService.js @@ -0,0 +1,314 @@ +const axios = require('axios'); + +// Firecrawl configuration +const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY; +const FIRECRAWL_BASE_URL = 'https://api.firecrawl.dev/v2/agent'; + +/** + * Start an enrichment job and return job ID immediately + * @param {Object} alumni - The alumni record to enrich + * @returns {Promise} Result with jobId + */ +async function startEnrichmentJob(alumni) { + const missingFields = getMissingFields(alumni); + + if (missingFields.length === 0) { + return { success: true, skipped: true, reason: 'no_missing_fields' }; + } + + const prompt = buildPrompt(alumni, missingFields); + + try { + const response = await axios.post( + FIRECRAWL_BASE_URL, + { prompt: prompt }, + { + headers: { + 'Authorization': `Bearer ${FIRECRAWL_API_KEY}`, + 'Content-Type': 'application/json' + } + } + ); + + const jobId = response.data.jobId || response.data.id; + console.log(`Firecrawl job started with ID: ${jobId}`); + + return { success: true, jobId: jobId, missingFields }; + } catch (error) { + console.error(`Failed to start Firecrawl job:`, error.message); + return { + success: false, + error: error.response?.data?.error || error.message + }; + } +} + +/** + * Poll Firecrawl job and get enriched data + * @param {String} jobId - The Firecrawl job ID + * @returns {Promise} The job result data + */ +async function getEnrichedData(jobId) { + const pollUrl = `${FIRECRAWL_BASE_URL}/${jobId}`; + + try { + const response = await axios.get(pollUrl, { + headers: { + 'Authorization': `Bearer ${FIRECRAWL_API_KEY}` + } + }); + + const job = response.data; + const status = job.status; + + if (status === 'completed' || status === 'success') { + const data = {}; + for (const [key, value] of Object.entries(job.data || {})) { + const schemaField = mapFirecrawlFieldToSchema(key); + if (value && value !== '') { + data[schemaField] = value; + } + } + return { success: true, status: 'completed', data }; + } else if (status === 'failed' || status === 'error') { + return { success: false, status: 'failed', error: job.error || 'Firecrawl job failed' }; + } + + return { success: true, status: 'pending' }; + } catch (error) { + console.error('Error polling Firecrawl job:', error.message); + return { success: false, error: error.message }; + } +} + +/** + * Main enrichment function - starts job and returns jobId for polling + * @param {Object} alumni - The alumni record with name and optional graduationYear + * @returns {Promise} Result with jobId for polling + */ +async function enrichAlumni(alumni) { + return startEnrichmentJob(alumni); +} + +/** + * Determine which fields are empty or null in an alumni record + * @param {Object} alumni - The alumni record + * @returns {Array} Array of Firecrawl field names that need enrichment + */ +function getMissingFields(alumni) { + const missingFields = []; + + if (!alumni.currentCompany || alumni.currentCompany.trim() === '') { + missingFields.push('current_company'); + } + if (!alumni.currentJobTitle || alumni.currentJobTitle.trim() === '') { + missingFields.push('current_job_title'); + } + if (!alumni.graduationYear) { + missingFields.push('graduation_year'); + } + if (!alumni.location || alumni.location.trim() === '') { + missingFields.push('location'); + } + if (!alumni.linkedInUrl || alumni.linkedInUrl.trim() === '') { + missingFields.push('linkedin_profile_url'); + } + if (!alumni.bio || alumni.bio.trim() === '') { + missingFields.push('bio'); + } + if (!alumni.headline || alumni.headline.trim() === '') { + missingFields.push('headline'); + } + if (!alumni.startYear) { + missingFields.push('start_year'); + } + if (!alumni.major || alumni.major.trim() === '') { + missingFields.push('major'); + } + + return missingFields; +} + +/** + * Build a dynamic prompt for Firecrawl based on missing fields + * @param {Object} alumni - The alumni record + * @param {Array} missingFields - Array of field names to request + * @returns {String} The prompt to send to Firecrawl + */ +function buildPrompt(alumni, missingFields) { + const fieldsList = missingFields.join(', '); + return `Find information about ${alumni.name}, an SJSU alumnus. If there are multiple people with this name, choose one who has any association with a club called The Software and Computer Engineering Society or SCE. Return ONLY these fields: ${fieldsList}. Try to find the most recent information (2024-2025). If recent data is not available, return whatever you can find. Provide accurate, factual data.`; +} + +/** + * Poll Firecrawl job status until completion or failure + * @param {String} jobId - The Firecrawl job ID + * @param {Number} timeoutMs - Maximum time to wait in milliseconds (default 5 minutes) + * @returns {Promise} The job result data + */ +async function pollFirecrawlJob(jobId, timeoutMs = 15 * 60 * 1000) { // 15 minutes + const pollUrl = `${FIRECRAWL_BASE_URL}/${jobId}`; + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + try { + const response = await axios.get(pollUrl, { + headers: { + 'Authorization': `Bearer ${FIRECRAWL_API_KEY}` + } + }); + + const job = response.data; + const status = job.status; + + if (status === 'completed' || status === 'success') { + return { success: true, data: job.data || job }; + } else if (status === 'failed' || status === 'error') { + return { success: false, error: job.error || 'Firecrawl job failed' }; + } + + // Status is 'pending' or 'running' - wait and poll again + await new Promise(resolve => setTimeout(resolve, 10000)); // 10 second delay + } catch (error) { + console.error('Error polling Firecrawl job:', error.message); + throw error; + } + } + + throw new Error('Firecrawl job timeout after 5 minutes'); +} + +/** + * Start a Firecrawl agent job to enrich alumni data + * @param {Object} alumni - The alumni record to enrich + * @returns {Promise} Result with success flag and optional data/error + */ +async function enrichAlumniRecord(alumni) { + // Check if enrichment is already in progress or completed + if (alumni.enrichmentStatus === 'pending') { + console.log(`Enrichment already in progress for alumni ${alumni._id}`); + return { success: true, skipped: true, reason: 'already_pending' }; + } + + // Determine which fields are missing + const missingFields = getMissingFields(alumni); + + if (missingFields.length === 0) { + console.log(`No missing fields for alumni ${alumni._id}, skipping enrichment`); + return { success: true, skipped: true, reason: 'no_missing_fields' }; + } + + console.log(`Starting enrichment for alumni ${alumni._id}, missing fields: ${missingFields.join(', ')}`); + + // Build the prompt + const prompt = buildPrompt(alumni, missingFields); + + try { + // Start the Firecrawl agent job + const response = await axios.post( + FIRECRAWL_BASE_URL, + { + prompt: prompt + }, + { + headers: { + 'Authorization': `Bearer ${FIRECRAWL_API_KEY}`, + 'Content-Type': 'application/json' + } + } + ); + + const jobId = response.data.jobId || response.data.id; + console.log(`Firecrawl job started with ID: ${jobId}`); + + // Update the alumni record with job ID and pending status + alumni.enrichmentJobId = jobId; + alumni.enrichmentStatus = 'pending'; + await alumni.save(); + + // Poll for completion (this can be done in background) + pollFirecrawlJob(jobId) + .then(async (result) => { + if (result.success) { + // Update only the fields that were requested + const updateData = {}; + + missingFields.forEach(field => { + const value = result.data[field]; + if (value && value !== '') { + // Map Firecrawl field names to our schema fields + const schemaField = mapFirecrawlFieldToSchema(field); + updateData[schemaField] = value; + } + }); + + if (Object.keys(updateData).length > 0) { + updateData.enrichmentStatus = 'completed'; + updateData.enrichmentJobId = jobId; + + // Use the model to update + const Alumni = require('../models/Alumni'); + await Alumni.findByIdAndUpdate(alumni._id, updateData); + console.log(`Enrichment completed for alumni ${alumni._id}:`, updateData); + } else { + // No data returned + alumni.enrichmentStatus = 'failed'; + await alumni.save(); + console.log(`Enrichment returned no data for alumni ${alumni._id}`); + } + } else { + // Job failed + alumni.enrichmentStatus = 'failed'; + await alumni.save(); + console.error(`Enrichment failed for alumni ${alumni._id}:`, result.error); + } + }) + .catch(async (error) => { + console.error(`Enrichment error for alumni ${alumni._id}:`, error.message); + alumni.enrichmentStatus = 'failed'; + await alumni.save(); + }); + + return { success: true, jobId: jobId, status: 'pending' }; + + } catch (error) { + console.error(`Failed to start Firecrawl job for alumni ${alumni._id}:`, error.message); + + // Mark as failed + alumni.enrichmentStatus = 'failed'; + await alumni.save(); + + return { + success: false, + error: error.response?.data?.error || error.message, + status: 'failed' + }; + } +} + +/** + * Map Firecrawl field names to Alumni schema field names + * @param {String} firecrawlField - Field name from Firecrawl + * @returns {String} Corresponding field name in Alumni schema + */ +function mapFirecrawlFieldToSchema(firecrawlField) { + const fieldMap = { + 'current_company': 'currentCompany', + 'current_job_title': 'currentJobTitle', + 'graduation_year': 'graduationYear', + 'location': 'location', + 'linkedin_profile_url': 'linkedInUrl', + 'bio': 'bio', + 'headline': 'headline', + 'start_year': 'startYear', + 'major': 'major' + }; + + return fieldMap[firecrawlField] || firecrawlField; +} + +module.exports = { + enrichAlumni, + startEnrichmentJob, + getEnrichedData +}; diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index ad715a8..45ef728 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -14,6 +14,7 @@ services: environment: - DATABASE_HOST=alumni-mongodb - NODE_ENV=development + - FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY} ports: - '8081:8081' depends_on: diff --git a/index.html b/index.html new file mode 100644 index 0000000..ed3e8a3 --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + SCE Alumni Directory + + +
+ + + diff --git a/package-lock.json b/package-lock.json index 8fbe240..f72d96b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "axios": "^1.6.0", + "dotenv": "^16.3.0", "express": "^5.2.1", "mongoose": "^9.3.1" }, @@ -67,6 +69,23 @@ "node": ">= 8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -212,6 +231,18 @@ "fsevents": "~2.3.2" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -269,6 +300,15 @@ } } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -278,6 +318,18 @@ "node": ">= 0.8" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -337,6 +389,21 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -429,6 +496,63 @@ "url": "https://opencollective.com/express" } }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -555,6 +679,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -993,6 +1132,12 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/pstree.remy": { "version": "1.1.8", "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", diff --git a/package.json b/package.json index abf912f..1389e7b 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "node api/server.js", "dev": "nodemon api/server.js", + "build": "vite build", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { @@ -21,10 +22,16 @@ }, "homepage": "https://github.com/SCE-Development/sce-linkedin#readme", "dependencies": { + "axios": "^1.6.0", + "dotenv": "^16.3.0", "express": "^5.2.1", - "mongoose": "^9.3.1" + "mongoose": "^9.3.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" }, "devDependencies": { - "nodemon": "^3.1.14" + "@vitejs/plugin-react": "^4.2.0", + "nodemon": "^3.1.14", + "vite": "^5.0.0" } } diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..758d0f1 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,662 @@ +import { useState, useEffect, useCallback } from 'react'; +import axios from 'axios'; + +const API_URL = '/api'; + +function App() { + const [alumniList, setAlumniList] = useState([]); + const [loading, setLoading] = useState(true); + const [editAlumni, setEditAlumni] = useState(null); + const [modalOpen, setModalOpen] = useState(false); + const [message, setMessage] = useState({ text: '', type: '' }); + + // Fetch all alumni + const loadAlumni = useCallback(async () => { + try { + setLoading(true); + const response = await axios.get(`${API_URL}/alumni`); + setAlumniList(response.data); + } catch (error) { + showAlert(error.response?.data?.error || error.message, 'error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadAlumni(); + }, [loadAlumni]); + + // Create alumni + const handleCreate = async (formData) => { + try { + await axios.post(`${API_URL}/alumni`, formData); + showAlert('Alumni created successfully!', 'success'); + loadAlumni(); + } catch (error) { + showAlert(error.response?.data?.error || error.message, 'error'); + } + }; + + // Update alumni + const handleUpdate = async (id, formData) => { + try { + await axios.put(`${API_URL}/alumni/${id}`, formData); + showAlert('Alumni updated successfully!', 'success'); + setModalOpen(false); + loadAlumni(); + } catch (error) { + showAlert(error.response?.data?.error || error.message, 'error'); + } + }; + + // Delete alumni + const handleDelete = async (id) => { + if (!window.confirm('Are you sure you want to delete this alumni?')) return; + + try { + await axios.delete(`${API_URL}/alumni/${id}`); + showAlert('Alumni deleted successfully!', 'success'); + loadAlumni(); + } catch (error) { + showAlert(error.response?.data?.error || error.message, 'error'); + } + }; + + // Poll enrichment status + const pollEnrichment = (alumniId) => { + const maxPolls = 90; // 15 minutes at 10s intervals + let polls = 0; + + const interval = setInterval(async () => { + polls++; + + try { + const response = await axios.get(`${API_URL}/alumni/${alumniId}`); + const updated = response.data; + + // Update the specific alumni in the list + setAlumniList(prev => + prev.map(a => (a._id === alumniId ? updated : a)) + ); + + if (updated.enrichmentStatus !== 'pending' || polls >= maxPolls) { + clearInterval(interval); + } + } catch (error) { + console.error('Polling error:', error); + clearInterval(interval); + } + }, 10000); + }; + + function showAlert(text, type = 'success') { + setMessage({ text, type }); + setTimeout(() => setMessage({ text: '', type: '' }), 5000); + } + + const openEditModal = (alumni) => { + setEditAlumni(alumni); + setModalOpen(true); + }; + + return ( +
+
+

🎓 SCE Alumni Directory

+

Manage alumni profiles with AI-powered enrichment

+
+ +
+
+

Add New Alumni

+ {message.text && ( +
{message.text}
+ )} + +
+ +
+

All Alumni

+ {loading ? ( +
Loading...
+ ) : ( + + )} +
+
+ + {modalOpen && editAlumni && ( + handleUpdate(editAlumni._id, data)} + onClose={() => setModalOpen(false)} + /> + )} +
+ ); +} + +// Alumni Form Component +function AlumniForm({ onSubmit }) { + const [formData, setFormData] = useState({ + name: '', + bio: '', + headline: '', + profilePhotoUrl: '', + linkedInUrl: '', + startYear: '', + graduationYear: '', + major: '', + }); + + const [isEnriching, setIsEnriching] = useState(false); + const [enrichJobId, setEnrichJobId] = useState(null); + const [enrichError, setEnrichError] = useState(''); + + const handleEnrich = async () => { + if (!formData.name.trim()) { + setEnrichError('Please enter a name first'); + return; + } + + setIsEnriching(true); + setEnrichError(''); + setEnrichJobId(null); + + try { + const response = await axios.post(`${API_URL}/alumni/enrich`, { + name: formData.name, + graduationYear: formData.graduationYear ? parseInt(formData.graduationYear) : null, + }); + + setEnrichJobId(response.data.jobId); + pollEnrichment(response.data.jobId); + } catch (error) { + setEnrichError(error.response?.data?.error || error.message); + setIsEnriching(false); + } + }; + + const pollEnrichment = (jobId) => { + const interval = setInterval(async () => { + try { + const response = await axios.get(`${API_URL}/alumni/enrich/${jobId}`); + + if (response.data.status === 'completed') { + clearInterval(interval); + setIsEnriching(false); + + if (response.data.data) { + setFormData((prev) => ({ + ...prev, + ...response.data.data, + })); + } + } else if (response.data.status === 'failed') { + clearInterval(interval); + setIsEnriching(false); + setEnrichError(response.data.error || 'Enrichment failed'); + } + } catch (error) { + console.error('Polling error:', error); + } + }, 3000); + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const payload = { + ...formData, + startYear: formData.startYear ? parseInt(formData.startYear) : null, + graduationYear: formData.graduationYear + ? parseInt(formData.graduationYear) + : null, + }; + onSubmit(payload); + setFormData({ + name: '', + bio: '', + headline: '', + profilePhotoUrl: '', + linkedInUrl: '', + startYear: '', + graduationYear: '', + major: '', + }); + }; + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + }; + + const inputBlurClass = isEnriching ? 'input-blur' : ''; + + return ( +
+
+ +
+ + +
+ {enrichError && {enrichError}} +
+ + {isEnriching && ( +
+ Fetching data... Please wait. +
+ )} + +
+ +