From f23881a236405ba57dcca3986c39b181faa71600 Mon Sep 17 00:00:00 2001 From: vineeshah Date: Tue, 24 Mar 2026 13:07:53 -0700 Subject: [PATCH 1/6] feat: firecrawl enrichment service --- .gitignore | 1 + api/models/Alumni.js | 25 ++++ api/routes/Alumni.js | 83 ++++++++++++ api/server.js | 3 + api/services/enrichmentService.js | 213 ++++++++++++++++++++++++++++++ package-lock.json | 145 ++++++++++++++++++++ package.json | 2 + 7 files changed, 472 insertions(+) create mode 100644 api/routes/Alumni.js create mode 100644 api/services/enrichmentService.js 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/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 new file mode 100644 index 0000000..580b96a --- /dev/null +++ b/api/routes/Alumni.js @@ -0,0 +1,83 @@ +const express = require('express'); +const Alumni = require('../models/Alumni'); +const { enrichAlumniRecord } = require('../services/enrichmentService'); +require('dotenv').config(); + +const router = express.Router(); + +// List all alumni profiles +router.get('/', async (req, res) => { + try { + const alumni = await Alumni.find(); + res.json(alumni); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Get a single alumni profile by ID +router.get('/:id', async (req, res) => { + try { + const alumni = await Alumni.findById(req.params.id); + if (!alumni) return res.status(404).json({ error: 'Alumni not found' }); + res.json(alumni); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Create a new alumni profile +router.post('/', async (req, res) => { + try { + // Ensure name is provided (required for enrichment) + if (!req.body.name) { + return res.status(400).json({ error: 'Name is required for alumni record' }); + } + + const alumni = await new Alumni(req.body).save(); + + // Check if enrichment is requested + if (req.body.needsEnrichment === true) { + // Trigger enrichment in the background (fire and forget) + enrichAlumniRecord(alumni) + .then(result => { + console.log('Enrichment triggered:', result); + }) + .catch(error => { + console.error('Enrichment trigger error:', error); + }); + } + + // Return the created alumni immediately + res.status(201).json(alumni); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Update an existing alumni profile +router.put('/:id', async (req, res) => { + try { + const alumni = await Alumni.findByIdAndUpdate(req.params.id, req.body, { + new: true, + runValidators: true, + }); + if (!alumni) return res.status(404).json({ error: 'Alumni not found' }); + res.json(alumni); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +// Delete an alumni profile +router.delete('/:id', async (req, res) => { + try { + const alumni = await Alumni.findByIdAndDelete(req.params.id); + if (!alumni) return res.status(404).json({ error: 'Alumni not found' }); + res.json(alumni); + } catch (err) { + res.status(500).json({ error: err.message }); + } +}); + +module.exports = router; diff --git a/api/server.js b/api/server.js index 6ddfa0d..42cb648 100644 --- a/api/server.js +++ b/api/server.js @@ -1,10 +1,13 @@ const express = require('express'); const mongoose = require('mongoose'); +const alumniRouter = require('./routes/Alumni'); +require('dotenv').config(); const app = express(); const PORT = 8081; app.use(express.json()); +app.use('/api', alumniRouter); const dbHost = process.env.DATABASE_HOST || '127.0.0.1'; mongoose diff --git a/api/services/enrichmentService.js b/api/services/enrichmentService.js new file mode 100644 index 0000000..359556d --- /dev/null +++ b/api/services/enrichmentService.js @@ -0,0 +1,213 @@ +const axios = require('axios'); + +// Firecrawl configuration +const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY; +const FIRECRAWL_BASE_URL = 'https://api.firecrawl.dev/v1/agent'; + +/** + * 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'); + } + + 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. 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 = 5 * 60 * 1000) { + 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, + wait_for_result: false // We'll poll manually + }, + { + 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' + }; + + return fieldMap[firecrawlField] || firecrawlField; +} + +module.exports = { + enrichAlumniRecord, + getMissingFields, + buildPrompt +}; 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..abf6d6d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ }, "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" }, From ec8900ac550309f91b2fc6b891d741e8e6b83cba Mon Sep 17 00:00:00 2001 From: vineeshah Date: Tue, 24 Mar 2026 15:13:04 -0700 Subject: [PATCH 2/6] simple html frontend for testing --- api/routes/Alumni.js | 26 ++- api/server.js | 4 +- api/services/enrichmentService.js | 7 +- docker-compose.dev.yml | 1 + public/app.js | 296 ++++++++++++++++++++++++++++ public/index.html | 128 ++++++++++++ public/styles.css | 311 ++++++++++++++++++++++++++++++ 7 files changed, 762 insertions(+), 11 deletions(-) create mode 100644 public/app.js create mode 100644 public/index.html create mode 100644 public/styles.css diff --git a/api/routes/Alumni.js b/api/routes/Alumni.js index 580b96a..270a78a 100644 --- a/api/routes/Alumni.js +++ b/api/routes/Alumni.js @@ -1,12 +1,21 @@ const express = require('express'); const Alumni = require('../models/Alumni'); const { enrichAlumniRecord } = require('../services/enrichmentService'); -require('dotenv').config(); + +// 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); @@ -16,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' }); @@ -27,13 +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 (required for enrichment) 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(); // Check if enrichment is requested @@ -56,7 +70,7 @@ router.post('/', async (req, res) => { }); // 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, @@ -70,7 +84,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 42cb648..6c11f81 100644 --- a/api/server.js +++ b/api/server.js @@ -1,12 +1,14 @@ +require('dotenv').config(); + const express = require('express'); const mongoose = require('mongoose'); const alumniRouter = require('./routes/Alumni'); -require('dotenv').config(); const app = express(); const PORT = 8081; app.use(express.json()); +app.use(express.static('public')); app.use('/api', alumniRouter); const dbHost = process.env.DATABASE_HOST || '127.0.0.1'; diff --git a/api/services/enrichmentService.js b/api/services/enrichmentService.js index 359556d..6a2de4e 100644 --- a/api/services/enrichmentService.js +++ b/api/services/enrichmentService.js @@ -2,7 +2,7 @@ const axios = require('axios'); // Firecrawl configuration const FIRECRAWL_API_KEY = process.env.FIRECRAWL_API_KEY; -const FIRECRAWL_BASE_URL = 'https://api.firecrawl.dev/v1/agent'; +const FIRECRAWL_BASE_URL = 'https://api.firecrawl.dev/v2/agent'; /** * Determine which fields are empty or null in an alumni record @@ -48,7 +48,7 @@ function buildPrompt(alumni, missingFields) { * @param {Number} timeoutMs - Maximum time to wait in milliseconds (default 5 minutes) * @returns {Promise} The job result data */ -async function pollFirecrawlJob(jobId, timeoutMs = 5 * 60 * 1000) { +async function pollFirecrawlJob(jobId, timeoutMs = 15 * 60 * 1000) { // 15 minutes const pollUrl = `${FIRECRAWL_BASE_URL}/${jobId}`; const startTime = Date.now(); @@ -110,8 +110,7 @@ async function enrichAlumniRecord(alumni) { const response = await axios.post( FIRECRAWL_BASE_URL, { - prompt: prompt, - wait_for_result: false // We'll poll manually + prompt: prompt }, { headers: { 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/public/app.js b/public/app.js new file mode 100644 index 0000000..4c932fc --- /dev/null +++ b/public/app.js @@ -0,0 +1,296 @@ +const API_URL = '/api'; + +// DOM Elements +const createForm = document.getElementById('createAlumniForm'); +const alumniGrid = document.getElementById('alumniGrid'); +const loading = document.getElementById('loading'); +const editModal = document.getElementById('editModal'); +const editForm = document.getElementById('editAlumniForm'); +const closeBtn = document.querySelector('.close'); + +// State +let alumniList = []; + +// Utility functions +function showAlert(message, type = 'success') { + const alert = document.createElement('div'); + alert.className = `alert alert-${type}`; + alert.textContent = message; + document.body.prepend(alert); + setTimeout(() => alert.remove(), 5000); +} + +function formatValue(value, defaultValue = 'N/A') { + if (value === null || value === undefined || value === '') { + return defaultValue; + } + return value; +} + +function getStatusBadge(status) { + const statusMap = { + 'pending': 'status-pending', + 'completed': 'status-completed', + 'failed': 'status-failed' + }; + const className = status ? statusMap[status] || 'status-none' : 'status-none'; + return `${status || 'not requested'}`; +} + +// Fetch all alumni +async function loadAlumni() { + try { + loading.style.display = 'block'; + alumniGrid.innerHTML = ''; + + const response = await fetch(`${API_URL}/alumni`); + if (!response.ok) throw new Error('Failed to fetch alumni'); + + alumniList = await response.json(); + renderAlumni(); + } catch (error) { + showAlert(error.message, 'error'); + } finally { + loading.style.display = 'none'; + } +} + +// Render alumni cards +function renderAlumni() { + alumniGrid.innerHTML = ''; + + if (alumniList.length === 0) { + alumniGrid.innerHTML = '

No alumni yet. Add one above!

'; + return; + } + + alumniList.forEach(alumni => { + const card = document.createElement('div'); + card.className = 'alumni-card'; + card.innerHTML = ` +

${formatValue(alumni.name)}

+
+
+ Company + ${formatValue(alumni.currentCompany)} +
+
+ Job Title + ${formatValue(alumni.currentJobTitle)} +
+
+ Location + ${formatValue(alumni.location)} +
+
+ LinkedIn + ${formatValue(alumni.linkedInUrl)} +
+
+ Graduation + ${formatValue(alumni.graduationYear)} +
+
+ Major + ${formatValue(alumni.major)} +
+
+ ${getStatusBadge(alumni.enrichmentStatus)} +
+ + +
+ `; + alumniGrid.appendChild(card); + }); +} + +// Create alumni +async function createAlumni(formData) { + try { + const response = await fetch(`${API_URL}/alumni`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to create alumni'); + } + + showAlert('Alumni created successfully!'); + createForm.reset(); + loadAlumni(); + + // If enrichment was requested, poll for updates + const created = await response.json(); + if (created.enrichmentStatus === 'pending') { + pollEnrichment(created._id); + } + } catch (error) { + showAlert(error.message, 'error'); + } +} + +// Poll enrichment status +function pollEnrichment(alumniId) { + const maxPolls = 90; // 15 minutes at 10s intervals + let polls = 0; + + const interval = setInterval(async () => { + polls++; + + try { + const response = await fetch(`${API_URL}/alumni/${alumniId}`); + if (!response.ok) return; + + const alumni = await response.json(); + + // Update the specific card in the list + const index = alumniList.findIndex(a => a._id === alumniId); + if (index !== -1) { + alumniList[index] = alumni; + renderAlumni(); + } + + if (alumni.enrichmentStatus !== 'pending' || polls >= maxPolls) { + clearInterval(interval); + } + } catch (error) { + console.error('Polling error:', error); + clearInterval(interval); + } + }, 10000); // Poll every 10 seconds +} + +// Delete alumni +async function deleteAlumni(id) { + if (!confirm('Are you sure you want to delete this alumni?')) return; + + try { + const response = await fetch(`${API_URL}/alumni/${id}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to delete alumni'); + } + + showAlert('Alumni deleted successfully!'); + loadAlumni(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +// Edit alumni - open modal +function openEditModal(id) { + const alumni = alumniList.find(a => a._id === id); + if (!alumni) return; + + document.getElementById('editId').value = alumni._id; + document.getElementById('editName').value = alumni.name || ''; + document.getElementById('editBio').value = alumni.bio || ''; + document.getElementById('editHeadline').value = alumni.headline || ''; + document.getElementById('editProfilePhotoUrl').value = alumni.profilePhotoUrl || ''; + document.getElementById('editLinkedInUrl').value = alumni.linkedInUrl || ''; + document.getElementById('editStartYear').value = alumni.startYear || ''; + document.getElementById('editGraduationYear').value = alumni.graduationYear || ''; + document.getElementById('editMajor').value = alumni.major || ''; + document.getElementById('editCurrentCompany').value = alumni.currentCompany || ''; + document.getElementById('editCurrentJobTitle').value = alumni.currentJobTitle || ''; + document.getElementById('editLocation').value = alumni.location || ''; + + editModal.style.display = 'block'; +} + +// Update alumni +async function updateAlumni(formData, id) { + try { + const response = await fetch(`${API_URL}/alumni/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(formData) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to update alumni'); + } + + showAlert('Alumni updated successfully!'); + editModal.style.display = 'none'; + loadAlumni(); + } catch (error) { + showAlert(error.message, 'error'); + } +} + +// Event Listeners +createForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const formData = { + name: document.getElementById('name').value, + bio: document.getElementById('bio').value, + headline: document.getElementById('headline').value, + profilePhotoUrl: document.getElementById('profilePhotoUrl').value, + linkedInUrl: document.getElementById('linkedInUrl').value, + startYear: document.getElementById('startYear').value + ? parseInt(document.getElementById('startYear').value) + : null, + graduationYear: document.getElementById('graduationYear').value + ? parseInt(document.getElementById('graduationYear').value) + : null, + major: document.getElementById('major').value, + needsEnrichment: document.getElementById('needsEnrichment').checked + }; + + await createAlumni(formData); +}); + +editForm.addEventListener('submit', async (e) => { + e.preventDefault(); + + const id = document.getElementById('editId').value; + const formData = { + name: document.getElementById('editName').value, + bio: document.getElementById('editBio').value, + headline: document.getElementById('editHeadline').value, + profilePhotoUrl: document.getElementById('editProfilePhotoUrl').value, + linkedInUrl: document.getElementById('editLinkedInUrl').value, + startYear: document.getElementById('editStartYear').value + ? parseInt(document.getElementById('editStartYear').value) + : null, + graduationYear: document.getElementById('editGraduationYear').value + ? parseInt(document.getElementById('editGraduationYear').value) + : null, + major: document.getElementById('editMajor').value, + currentCompany: document.getElementById('editCurrentCompany').value, + currentJobTitle: document.getElementById('editCurrentJobTitle').value, + location: document.getElementById('editLocation').value + }; + + await updateAlumni(formData, id); +}); + +closeBtn.addEventListener('click', () => { + editModal.style.display = 'none'; +}); + +window.addEventListener('click', (e) => { + if (e.target === editModal) { + editModal.style.display = 'none'; + } +}); + +// document.addEventListener('keydown', (e) => { +// if (e.key === 'Escape') { +// editModal.style.display = 'none'; +// } +// }); + +// Initial load +loadAlumni(); diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..3aafc94 --- /dev/null +++ b/public/index.html @@ -0,0 +1,128 @@ + + + + + + SCE Alumni Directory + + + +
+
+

🎓 SCE Alumni Directory

+

Manage alumni profiles with AI-powered enrichment

+
+ +
+ +
+

Add New Alumni

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+

All Alumni

+
Loading...
+
+
+
+
+ + + + + + + diff --git a/public/styles.css b/public/styles.css new file mode 100644 index 0000000..2a46ba9 --- /dev/null +++ b/public/styles.css @@ -0,0 +1,311 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + padding: 20px; +} + +.container { + max-width: 1200px; + margin: 0 auto; +} + +header { + text-align: center; + color: white; + margin-bottom: 30px; +} + +header h1 { + font-size: 2.5rem; + margin-bottom: 10px; +} + +header p { + opacity: 0.9; + font-size: 1.1rem; +} + +main { + display: grid; + grid-template-columns: 400px 1fr; + gap: 30px; +} + +@media (max-width: 900px) { + main { + grid-template-columns: 1fr; + } +} + +.create-form, +.alumni-list { + background: white; + border-radius: 12px; + padding: 25px; + box-shadow: 0 10px 30px rgba(0,0,0,0.2); +} + +.create-form h2, +.alumni-list h2 { + color: #333; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 2px solid #667eea; +} + +.form-group { + margin-bottom: 15px; +} + +.form-group label { + display: block; + margin-bottom: 5px; + font-weight: 500; + color: #555; +} + +.form-group input[type="text"], +.form-group input[type="url"], +.form-group input[type="number"], +.form-group textarea { + width: 100%; + padding: 10px; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.3s; +} + +.form-group input:focus, +.form-group textarea:focus { + outline: none; + border-color: #667eea; +} + +.form-group.checkbox { + display: flex; + align-items: center; + gap: 8px; +} + +.form-group.checkbox input { + width: auto; + margin: 0; +} + +.form-group.checkbox label { + margin: 0; + font-weight: normal; + color: #666; +} + +.btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.3s; + margin-top: 10px; + margin-right: 10px; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.btn-primary:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4); +} + +.btn-edit { + background: #4CAF50; + color: white; + padding: 6px 12px; + font-size: 12px; +} + +.btn-delete { + background: #f44336; + color: white; + padding: 6px 12px; + font-size: 12px; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; +} + +.loading { + text-align: center; + padding: 40px; + color: #666; +} + +.alumni-grid { + display: grid; + gap: 15px; +} + +.alumni-card { + background: #f9f9f9; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 15px; + transition: all 0.3s; +} + +.alumni-card:hover { + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + transform: translateY(-2px); +} + +.alumni-card h3 { + color: #333; + margin-bottom: 8px; + font-size: 1.2rem; +} + +.alumni-card .info { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 12px; +} + +.alumni-card .info-item { + display: flex; + flex-direction: column; +} + +.alumni-card .info-label { + font-size: 0.75rem; + text-transform: uppercase; + color: #888; + margin-bottom: 2px; +} + +.alumni-card .info-value { + font-size: 0.9rem; + color: #333; +} + +.alumni-card .status { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + margin-top: 10px; +} + +.status-pending { + background: #fff3cd; + color: #856404; +} + +.status-completed { + background: #d4edda; + color: #155724; +} + +.status-failed { + background: #f8d7da; + color: #721c24; +} + +.status-none { + background: #e2e3e5; + color: #383d41; +} + +.alumni-card .actions { + display: flex; + gap: 8px; + margin-top: 12px; +} + +.alumni-card .empty { + color: #999; + font-style: italic; + font-size: 0.85rem; +} + +/* Modal */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + z-index: 1000; + overflow-y: auto; + padding: 20px; +} + +.modal-content { + background: white; + max-width: 600px; + margin: 40px auto; + border-radius: 12px; + padding: 25px; + position: relative; + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-50px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.close { + position: absolute; + top: 15px; + right: 20px; + font-size: 28px; + cursor: pointer; + color: #666; +} + +.close:hover { + color: #333; +} + +.alert { + padding: 12px 20px; + border-radius: 6px; + margin-bottom: 15px; + font-weight: 500; +} + +.alert-success { + background: #d4edda; + color: #155724; + border: 1px solid #c3e6cb; +} + +.alert-error { + background: #f8d7da; + color: #721c24; + border: 1px solid #f5c6cb; +} From 5b4b9c34f55a638a6535e6a874b5b6c242300b35 Mon Sep 17 00:00:00 2001 From: vineeshah Date: Thu, 26 Mar 2026 16:58:09 -0700 Subject: [PATCH 3/6] sce context change --- README.md | 19 ++++++++++++++----- api/services/enrichmentService.js | 20 ++++++++++++++++++-- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 214dbe2..335b78a 100644 --- a/README.md +++ b/README.md @@ -4,14 +4,23 @@ Club's internal alumni directory ## Prerequisites - [Docker Desktop](https://www.docker.com/products/docker-desktop/) installed and running +- **Firecrawl API key** (get one at https://firecrawl.dev) -## 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 +APOLLO_API_KEY=your-key-here # optional +``` -# 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/services/enrichmentService.js b/api/services/enrichmentService.js index 6a2de4e..d5cef9f 100644 --- a/api/services/enrichmentService.js +++ b/api/services/enrichmentService.js @@ -27,6 +27,18 @@ function getMissingFields(alumni) { 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; } @@ -39,7 +51,7 @@ function getMissingFields(alumni) { */ function buildPrompt(alumni, missingFields) { const fieldsList = missingFields.join(', '); - return `Find information about ${alumni.name}, an SJSU alumnus. 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.`; + 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.`; } /** @@ -199,7 +211,11 @@ function mapFirecrawlFieldToSchema(firecrawlField) { 'current_job_title': 'currentJobTitle', 'graduation_year': 'graduationYear', 'location': 'location', - 'linkedin_profile_url': 'linkedInUrl' + 'linkedin_profile_url': 'linkedInUrl', + 'bio': 'bio', + 'headline': 'headline', + 'start_year': 'startYear', + 'major': 'major' }; return fieldMap[firecrawlField] || firecrawlField; From 3f7035689c965b3881ed3f4ad80a4b445a3b1e01 Mon Sep 17 00:00:00 2001 From: vineeshah Date: Thu, 26 Mar 2026 17:09:27 -0700 Subject: [PATCH 4/6] simple react frontend for testing --- Dockerfile | 15 +- api/server.js | 6 +- index.html | 12 + package.json | 9 +- public/app.js | 296 --------------- public/index.html | 128 ------- src/App.jsx | 573 +++++++++++++++++++++++++++++ public/styles.css => src/index.css | 57 +-- src/main.jsx | 10 + vite.config.js | 15 + 10 files changed, 665 insertions(+), 456 deletions(-) create mode 100644 index.html delete mode 100644 public/app.js delete mode 100644 public/index.html create mode 100644 src/App.jsx rename public/styles.css => src/index.css (93%) create mode 100644 src/main.jsx create mode 100644 vite.config.js 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/api/server.js b/api/server.js index 6c11f81..b3b41aa 100644 --- a/api/server.js +++ b/api/server.js @@ -8,7 +8,11 @@ const app = express(); const PORT = 8081; app.use(express.json()); -app.use(express.static('public')); + +// Serve React build from dist/ +app.use(express.static('dist')); + +// API routes app.use('/api', alumniRouter); const dbHost = process.env.DATABASE_HOST || '127.0.0.1'; 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.json b/package.json index abf6d6d..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": { @@ -24,9 +25,13 @@ "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/public/app.js b/public/app.js deleted file mode 100644 index 4c932fc..0000000 --- a/public/app.js +++ /dev/null @@ -1,296 +0,0 @@ -const API_URL = '/api'; - -// DOM Elements -const createForm = document.getElementById('createAlumniForm'); -const alumniGrid = document.getElementById('alumniGrid'); -const loading = document.getElementById('loading'); -const editModal = document.getElementById('editModal'); -const editForm = document.getElementById('editAlumniForm'); -const closeBtn = document.querySelector('.close'); - -// State -let alumniList = []; - -// Utility functions -function showAlert(message, type = 'success') { - const alert = document.createElement('div'); - alert.className = `alert alert-${type}`; - alert.textContent = message; - document.body.prepend(alert); - setTimeout(() => alert.remove(), 5000); -} - -function formatValue(value, defaultValue = 'N/A') { - if (value === null || value === undefined || value === '') { - return defaultValue; - } - return value; -} - -function getStatusBadge(status) { - const statusMap = { - 'pending': 'status-pending', - 'completed': 'status-completed', - 'failed': 'status-failed' - }; - const className = status ? statusMap[status] || 'status-none' : 'status-none'; - return `${status || 'not requested'}`; -} - -// Fetch all alumni -async function loadAlumni() { - try { - loading.style.display = 'block'; - alumniGrid.innerHTML = ''; - - const response = await fetch(`${API_URL}/alumni`); - if (!response.ok) throw new Error('Failed to fetch alumni'); - - alumniList = await response.json(); - renderAlumni(); - } catch (error) { - showAlert(error.message, 'error'); - } finally { - loading.style.display = 'none'; - } -} - -// Render alumni cards -function renderAlumni() { - alumniGrid.innerHTML = ''; - - if (alumniList.length === 0) { - alumniGrid.innerHTML = '

No alumni yet. Add one above!

'; - return; - } - - alumniList.forEach(alumni => { - const card = document.createElement('div'); - card.className = 'alumni-card'; - card.innerHTML = ` -

${formatValue(alumni.name)}

-
-
- Company - ${formatValue(alumni.currentCompany)} -
-
- Job Title - ${formatValue(alumni.currentJobTitle)} -
-
- Location - ${formatValue(alumni.location)} -
-
- LinkedIn - ${formatValue(alumni.linkedInUrl)} -
-
- Graduation - ${formatValue(alumni.graduationYear)} -
-
- Major - ${formatValue(alumni.major)} -
-
- ${getStatusBadge(alumni.enrichmentStatus)} -
- - -
- `; - alumniGrid.appendChild(card); - }); -} - -// Create alumni -async function createAlumni(formData) { - try { - const response = await fetch(`${API_URL}/alumni`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to create alumni'); - } - - showAlert('Alumni created successfully!'); - createForm.reset(); - loadAlumni(); - - // If enrichment was requested, poll for updates - const created = await response.json(); - if (created.enrichmentStatus === 'pending') { - pollEnrichment(created._id); - } - } catch (error) { - showAlert(error.message, 'error'); - } -} - -// Poll enrichment status -function pollEnrichment(alumniId) { - const maxPolls = 90; // 15 minutes at 10s intervals - let polls = 0; - - const interval = setInterval(async () => { - polls++; - - try { - const response = await fetch(`${API_URL}/alumni/${alumniId}`); - if (!response.ok) return; - - const alumni = await response.json(); - - // Update the specific card in the list - const index = alumniList.findIndex(a => a._id === alumniId); - if (index !== -1) { - alumniList[index] = alumni; - renderAlumni(); - } - - if (alumni.enrichmentStatus !== 'pending' || polls >= maxPolls) { - clearInterval(interval); - } - } catch (error) { - console.error('Polling error:', error); - clearInterval(interval); - } - }, 10000); // Poll every 10 seconds -} - -// Delete alumni -async function deleteAlumni(id) { - if (!confirm('Are you sure you want to delete this alumni?')) return; - - try { - const response = await fetch(`${API_URL}/alumni/${id}`, { - method: 'DELETE' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete alumni'); - } - - showAlert('Alumni deleted successfully!'); - loadAlumni(); - } catch (error) { - showAlert(error.message, 'error'); - } -} - -// Edit alumni - open modal -function openEditModal(id) { - const alumni = alumniList.find(a => a._id === id); - if (!alumni) return; - - document.getElementById('editId').value = alumni._id; - document.getElementById('editName').value = alumni.name || ''; - document.getElementById('editBio').value = alumni.bio || ''; - document.getElementById('editHeadline').value = alumni.headline || ''; - document.getElementById('editProfilePhotoUrl').value = alumni.profilePhotoUrl || ''; - document.getElementById('editLinkedInUrl').value = alumni.linkedInUrl || ''; - document.getElementById('editStartYear').value = alumni.startYear || ''; - document.getElementById('editGraduationYear').value = alumni.graduationYear || ''; - document.getElementById('editMajor').value = alumni.major || ''; - document.getElementById('editCurrentCompany').value = alumni.currentCompany || ''; - document.getElementById('editCurrentJobTitle').value = alumni.currentJobTitle || ''; - document.getElementById('editLocation').value = alumni.location || ''; - - editModal.style.display = 'block'; -} - -// Update alumni -async function updateAlumni(formData, id) { - try { - const response = await fetch(`${API_URL}/alumni/${id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(formData) - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to update alumni'); - } - - showAlert('Alumni updated successfully!'); - editModal.style.display = 'none'; - loadAlumni(); - } catch (error) { - showAlert(error.message, 'error'); - } -} - -// Event Listeners -createForm.addEventListener('submit', async (e) => { - e.preventDefault(); - - const formData = { - name: document.getElementById('name').value, - bio: document.getElementById('bio').value, - headline: document.getElementById('headline').value, - profilePhotoUrl: document.getElementById('profilePhotoUrl').value, - linkedInUrl: document.getElementById('linkedInUrl').value, - startYear: document.getElementById('startYear').value - ? parseInt(document.getElementById('startYear').value) - : null, - graduationYear: document.getElementById('graduationYear').value - ? parseInt(document.getElementById('graduationYear').value) - : null, - major: document.getElementById('major').value, - needsEnrichment: document.getElementById('needsEnrichment').checked - }; - - await createAlumni(formData); -}); - -editForm.addEventListener('submit', async (e) => { - e.preventDefault(); - - const id = document.getElementById('editId').value; - const formData = { - name: document.getElementById('editName').value, - bio: document.getElementById('editBio').value, - headline: document.getElementById('editHeadline').value, - profilePhotoUrl: document.getElementById('editProfilePhotoUrl').value, - linkedInUrl: document.getElementById('editLinkedInUrl').value, - startYear: document.getElementById('editStartYear').value - ? parseInt(document.getElementById('editStartYear').value) - : null, - graduationYear: document.getElementById('editGraduationYear').value - ? parseInt(document.getElementById('editGraduationYear').value) - : null, - major: document.getElementById('editMajor').value, - currentCompany: document.getElementById('editCurrentCompany').value, - currentJobTitle: document.getElementById('editCurrentJobTitle').value, - location: document.getElementById('editLocation').value - }; - - await updateAlumni(formData, id); -}); - -closeBtn.addEventListener('click', () => { - editModal.style.display = 'none'; -}); - -window.addEventListener('click', (e) => { - if (e.target === editModal) { - editModal.style.display = 'none'; - } -}); - -// document.addEventListener('keydown', (e) => { -// if (e.key === 'Escape') { -// editModal.style.display = 'none'; -// } -// }); - -// Initial load -loadAlumni(); diff --git a/public/index.html b/public/index.html deleted file mode 100644 index 3aafc94..0000000 --- a/public/index.html +++ /dev/null @@ -1,128 +0,0 @@ - - - - - - SCE Alumni Directory - - - -
-
-

🎓 SCE Alumni Directory

-

Manage alumni profiles with AI-powered enrichment

-
- -
- -
-

Add New Alumni

-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- - -
-

All Alumni

-
Loading...
-
-
-
-
- - - - - - - diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..9cf1910 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,573 @@ +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 { + const response = await axios.post(`${API_URL}/alumni`, formData); + showAlert('Alumni created successfully!', 'success'); + loadAlumni(); + + // Poll for enrichment if requested + if (response.data.enrichmentStatus === 'pending') { + pollEnrichment(response.data._id); + } + } 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: '', + needsEnrichment: false, + }); + + 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: '', + needsEnrichment: false, + }); + }; + + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + setFormData((prev) => ({ + ...prev, + [name]: type === 'checkbox' ? checked : value, + })); + }; + + return ( +
+
+ + +
+ +
+ +