1107 lines
35 KiB
JavaScript
Executable File
1107 lines
35 KiB
JavaScript
Executable File
/**************************************************
|
||
* server2.js - Reverted to SQLite version
|
||
**************************************************/
|
||
|
||
import express from 'express';
|
||
import axios from 'axios';
|
||
import cors from 'cors';
|
||
import helmet from 'helmet';
|
||
import dotenv from 'dotenv';
|
||
import xlsx from 'xlsx';
|
||
import path from 'path';
|
||
import { fileURLToPath } from 'url';
|
||
import { open } from 'sqlite';
|
||
import sqlite3 from 'sqlite3';
|
||
import pool from './config/mysqlPool.js'; // exports { query, execute, raw, ... }
|
||
import fs from 'fs';
|
||
import { readFile } from 'fs/promises'; // <-- add this
|
||
import readline from 'readline';
|
||
import chatFreeEndpoint from "./utils/chatFreeEndpoint.js";
|
||
import { OpenAI } from 'openai';
|
||
import rateLimit from 'express-rate-limit';
|
||
import authenticateUser from './utils/authenticateUser.js';
|
||
import { vectorSearch } from "./utils/vectorSearch.js";
|
||
import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
const rootPath = path.resolve(__dirname, '..');
|
||
const env = process.env.NODE_ENV?.trim() || 'development';
|
||
const envPath = path.resolve(rootPath, `.env.${env}`);
|
||
dotenv.config({ path: envPath, override: false }); // don't clobber compose-injected env
|
||
|
||
const ROOT_DIR = path.resolve(__dirname, '..');
|
||
const PUBLIC_DIR = path.join(ROOT_DIR, 'public');
|
||
const CIP_TO_SOC_PATH = path.join(PUBLIC_DIR, 'CIP_to_ONET_SOC.xlsx');
|
||
const INSTITUTION_DATA_PATH= path.join(PUBLIC_DIR, 'Institution_data.json');
|
||
const SALARY_DB_PATH = path.join(ROOT_DIR, 'salary_info.db');
|
||
const USER_PROFILE_DB_PATH = path.join(ROOT_DIR, 'user_profile.db');
|
||
|
||
for (const p of [CIP_TO_SOC_PATH, INSTITUTION_DATA_PATH, SALARY_DB_PATH, USER_PROFILE_DB_PATH]) {
|
||
if (!fs.existsSync(p)) {
|
||
console.error(`FATAL Required data file not found → ${p}`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||
|
||
const chatLimiter = rateLimit({
|
||
windowMs: 60 * 60 * 1000,
|
||
max: 20,
|
||
keyGenerator: req => req.user?.id || req.ip
|
||
});
|
||
|
||
// Load institution data (kept for existing routes)
|
||
const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8'));
|
||
|
||
// ── DEK + canary bootstrap (use raw pool to avoid DAO interception) ──
|
||
const db = pool.raw || pool;
|
||
|
||
try {
|
||
await initEncryption();
|
||
await db.query('SELECT 1');
|
||
await verifyCanary(db);
|
||
} catch (e) {
|
||
console.error('FATAL during crypto/DB bootstrap:', e?.message || e);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Create Express app
|
||
const app = express();
|
||
const PORT = process.env.SERVER2_PORT || 5001;
|
||
|
||
function fprPathFromEnv() {
|
||
const p = (process.env.DEK_PATH || '').trim();
|
||
return p ? path.join(path.dirname(p), 'dek.fpr') : null;
|
||
}
|
||
|
||
// 1) Liveness: process up
|
||
app.get('/livez', (_req, res) => res.type('text').send('OK'));
|
||
|
||
// 2) Readiness: DEK + canary OK
|
||
app.get('/readyz', async (_req, res) => {
|
||
try {
|
||
await initEncryption();
|
||
await verifyCanary(db); // <-- use raw pool
|
||
return res.type('text').send('OK');
|
||
} catch (e) {
|
||
console.error('[READYZ]', e.message);
|
||
return res.status(500).type('text').send('FAIL');
|
||
}
|
||
});
|
||
|
||
// 3) Health: detailed JSON you can curl
|
||
app.get('/healthz', async (_req, res) => {
|
||
const out = {
|
||
service: process.env.npm_package_name || 'server2',
|
||
version: process.env.IMG_TAG || null,
|
||
uptime_s: Math.floor(process.uptime()),
|
||
now: new Date().toISOString(),
|
||
checks: {
|
||
live: { ok: true },
|
||
crypto: { ok: false, fp: null },
|
||
db: { ok: false, ping_ms: null },
|
||
canary: { ok: false }
|
||
}
|
||
};
|
||
|
||
// crypto / DEK
|
||
try {
|
||
await initEncryption();
|
||
out.checks.crypto.ok = true;
|
||
const p = fprPathFromEnv();
|
||
if (p) {
|
||
try { out.checks.crypto.fp = (await readFile(p, 'utf8')).trim(); } catch {}
|
||
}
|
||
} catch (e) {
|
||
out.checks.crypto.error = e.message;
|
||
}
|
||
|
||
// DB ping
|
||
const t0 = Date.now();
|
||
try {
|
||
await db.query('SELECT 1'); // <-- use raw pool
|
||
out.checks.db.ok = true;
|
||
out.checks.db.ping_ms = Date.now() - t0;
|
||
} catch (e) {
|
||
out.checks.db.error = e.message;
|
||
}
|
||
|
||
// canary
|
||
try {
|
||
await verifyCanary(db); // <-- use raw pool
|
||
out.checks.canary.ok = true;
|
||
} catch (e) {
|
||
out.checks.canary.error = e.message;
|
||
}
|
||
|
||
const ready = out.checks.crypto.ok && out.checks.db.ok && out.checks.canary.ok;
|
||
return res.status(ready ? 200 : 503).json(out);
|
||
});
|
||
|
||
/**************************************************
|
||
* DB connections (SQLite)
|
||
**************************************************/
|
||
let dbSqlite;
|
||
let userProfileDb;
|
||
|
||
async function initDatabases() {
|
||
try {
|
||
dbSqlite = await open({
|
||
filename: SALARY_DB_PATH,
|
||
driver : sqlite3.Database,
|
||
mode : sqlite3.OPEN_READONLY
|
||
});
|
||
console.log('✅ Connected to salary_info.db');
|
||
|
||
userProfileDb = await open({
|
||
filename: USER_PROFILE_DB_PATH,
|
||
driver : sqlite3.Database
|
||
});
|
||
console.log('✅ Connected to user_profile.db');
|
||
} catch (err) {
|
||
console.error('❌ DB init failed →', err);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
await initDatabases();
|
||
|
||
// …rest of your routes and app.listen(PORT)
|
||
|
||
|
||
/* ──────────────────────────────────────────────────────────────
|
||
* SECURITY, CORS, JSON Body
|
||
* ────────────────────────────────────────────────────────────── */
|
||
|
||
/* 1 — Require critical env var up-front */
|
||
if (!process.env.CORS_ALLOWED_ORIGINS) {
|
||
console.error('FATAL CORS_ALLOWED_ORIGINS is not set');
|
||
process.exit(1);
|
||
}
|
||
|
||
/* 2 — Build allow-list from env (comma-separated) */
|
||
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||
.split(',')
|
||
.map(o => o.trim())
|
||
.filter(Boolean);
|
||
|
||
/* 3 — Security headers */
|
||
app.use(
|
||
helmet({
|
||
contentSecurityPolicy: false,
|
||
crossOriginEmbedderPolicy: false,
|
||
})
|
||
);
|
||
|
||
/* 4 — Dynamic CORS / pre-flight handling */
|
||
app.use((req, res, next) => {
|
||
const origin = req.headers.origin;
|
||
|
||
/* 4a — Whitelisted origins (credentials allowed) */
|
||
if (origin && allowedOrigins.includes(origin)) {
|
||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||
res.setHeader(
|
||
'Access-Control-Allow-Headers',
|
||
'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods'
|
||
);
|
||
res.setHeader(
|
||
'Access-Control-Allow-Methods',
|
||
'GET, POST, OPTIONS'
|
||
);
|
||
|
||
/* 4b — Public JSON exception */
|
||
} else if (req.path.includes('Institution_data')) {
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||
res.setHeader(
|
||
'Access-Control-Allow-Headers',
|
||
'Content-Type, Accept, Origin, X-Requested-With'
|
||
);
|
||
|
||
/* 4c — Default permissive fallback (same as your original) */
|
||
} else {
|
||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||
res.setHeader(
|
||
'Access-Control-Allow-Headers',
|
||
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
|
||
);
|
||
}
|
||
|
||
/* 4d — Short-circuit pre-flight requests */
|
||
if (req.method === 'OPTIONS') {
|
||
res.status(204).end();
|
||
return;
|
||
}
|
||
|
||
next();
|
||
});
|
||
|
||
/* 5 — JSON parsing & static assets */
|
||
app.use(express.json());
|
||
app.use(express.static(path.join(__dirname, 'public')));
|
||
|
||
/* 6 — No-op pass-through (kept for completeness) */
|
||
app.use((req, res, next) => next());
|
||
|
||
/**************************************************
|
||
* Load CIP->SOC mapping
|
||
**************************************************/
|
||
function loadMapping() {
|
||
const wb = xlsx.readFile(CIP_TO_SOC_PATH);
|
||
const sheet= wb.Sheets[wb.SheetNames[0]];
|
||
return xlsx.utils.sheet_to_json(sheet); // socToCipMapping array
|
||
}
|
||
const socToCipMapping = loadMapping();
|
||
if (socToCipMapping.length === 0) {
|
||
console.error('SOC to CIP mapping data is empty.');
|
||
}
|
||
|
||
/**************************************************
|
||
* Load single JSON with all states + US
|
||
* Replaces old GA-only approach
|
||
**************************************************/
|
||
const singleProjFile = path.resolve(__dirname, '..', 'public', 'economicproj.json');
|
||
let allProjections = [];
|
||
try {
|
||
const raw = fs.readFileSync(singleProjFile, 'utf8');
|
||
allProjections = JSON.parse(raw);
|
||
console.log(`Loaded ${allProjections.length} rows from economicproj.json`);
|
||
} catch (err) {
|
||
console.error('Error reading economicproj.json:', err);
|
||
}
|
||
|
||
//AI At Risk helpers
|
||
async function getRiskAnalysisFromDB(socCode) {
|
||
const row = await userProfileDb.get(
|
||
`SELECT * FROM ai_risk_analysis WHERE soc_code = ?`,
|
||
[socCode]
|
||
);
|
||
return row || null;
|
||
}
|
||
|
||
// Helper to upsert a row
|
||
async function storeRiskAnalysisInDB({
|
||
socCode,
|
||
careerName,
|
||
jobDescription,
|
||
tasks,
|
||
riskLevel,
|
||
reasoning
|
||
}) {
|
||
// 1) get existing row if any
|
||
const existing = await userProfileDb.get(
|
||
`SELECT * FROM ai_risk_analysis WHERE soc_code = ?`,
|
||
[socCode]
|
||
);
|
||
|
||
let finalJobDesc = jobDescription ?? "";
|
||
let finalTasks = tasks ?? "";
|
||
|
||
// 2) If existing row and the new jobDescription is blank => keep existing
|
||
if (existing) {
|
||
if (!jobDescription?.trim()) {
|
||
finalJobDesc = existing.job_description;
|
||
}
|
||
if (!tasks?.trim()) {
|
||
finalTasks = existing.tasks;
|
||
}
|
||
}
|
||
|
||
const sql = `
|
||
INSERT OR REPLACE INTO ai_risk_analysis (
|
||
soc_code,
|
||
career_name,
|
||
job_description,
|
||
tasks,
|
||
risk_level,
|
||
reasoning,
|
||
created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||
`;
|
||
await userProfileDb.run(sql, [
|
||
socCode,
|
||
careerName || existing?.career_name || '',
|
||
finalJobDesc || '',
|
||
finalTasks || '',
|
||
riskLevel || existing?.risk_level || '',
|
||
reasoning || existing?.reasoning || ''
|
||
]);
|
||
}
|
||
|
||
|
||
/**************************************************
|
||
* O*Net routes, CIP routes, distance routes, etc.
|
||
**************************************************/
|
||
|
||
// O*Net interest questions
|
||
app.get('/api/onet/questions', async (req, res) => {
|
||
const { start, end } = req.query;
|
||
if (!start || !end) {
|
||
return res.status(400).json({ error: 'Start and end parameters are required' });
|
||
}
|
||
try {
|
||
const questions = [];
|
||
let currentStart = parseInt(start, 10);
|
||
let currentEnd = parseInt(end, 10);
|
||
|
||
while (currentStart <= currentEnd) {
|
||
const response = await axios.get(
|
||
`https://services.onetcenter.org/ws/mnm/interestprofiler/questions?start=${currentStart}&end=${Math.min(
|
||
currentEnd,
|
||
currentStart + 11
|
||
)}`,
|
||
{
|
||
auth: {
|
||
username: process.env.ONET_USERNAME,
|
||
password: process.env.ONET_PASSWORD,
|
||
},
|
||
}
|
||
);
|
||
if (response.data.question && Array.isArray(response.data.question)) {
|
||
questions.push(...response.data.question);
|
||
}
|
||
const nextLink = response.data.link?.find((link) => link.rel === 'next');
|
||
if (nextLink) {
|
||
const nextParams = new URLSearchParams(nextLink.href.split('?')[1]);
|
||
currentStart = parseInt(nextParams.get('start'), 10);
|
||
currentEnd = parseInt(nextParams.get('end'), 10);
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
res.status(200).json({ questions });
|
||
} catch (error) {
|
||
console.error('Error fetching O*Net questions:', error.message);
|
||
res.status(500).json({ error: 'Failed to fetch O*Net questions' });
|
||
}
|
||
});
|
||
|
||
// geocode
|
||
async function geocodeZipCode(zipCode) {
|
||
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||
if (!apiKey) {
|
||
console.error('Google Maps API Key missing');
|
||
}
|
||
try {
|
||
const geocodeUrl = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(
|
||
zipCode
|
||
)}&components=country:US&key=${apiKey}`;
|
||
const response = await axios.get(geocodeUrl);
|
||
if (response.data.status === 'OK' && response.data.results.length > 0) {
|
||
return response.data.results[0].geometry.location;
|
||
} else {
|
||
throw new Error('Geocoding failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('Error geocoding ZIP code:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/** @aiTool {
|
||
"name": "getDistanceInMiles",
|
||
"description": "Return driving distance and duration between the user ZIP and destination(s)",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"userZipcode": { "type": "string" },
|
||
"destinations": { "type": "string", "description": "Pipe-separated lat,lng pairs or addresses" }
|
||
},
|
||
"required": ["userZipcode", "destinations"]
|
||
},
|
||
"pages": ["EducationalProgramsPage", "LoanRepayment"],
|
||
"write": false
|
||
} */
|
||
|
||
// Distance
|
||
app.post('/api/maps/distance', async (req, res) => {
|
||
const { userZipcode, destinations } = req.body;
|
||
if (!userZipcode || !destinations) {
|
||
console.error('Missing required parameters:', { userZipcode, destinations });
|
||
return res.status(400).json({ error: 'User ZIP code and destination are required.' });
|
||
}
|
||
try {
|
||
const googleMapsApiKey = process.env.GOOGLE_MAPS_API_KEY;
|
||
const userLocation = await geocodeZipCode(userZipcode);
|
||
if (!userLocation) {
|
||
return res.status(400).json({ error: 'Unable to geocode user ZIP code.' });
|
||
}
|
||
const origins = `${userLocation.lat},${userLocation.lng}`;
|
||
const distanceUrl = `https://maps.googleapis.com/maps/api/distancematrix/json?origins=${origins}&destinations=${encodeURIComponent(
|
||
destinations
|
||
)}&units=imperial&key=${googleMapsApiKey}`;
|
||
const distanceResponse = await axios.get(distanceUrl);
|
||
if (distanceResponse.data.status !== 'OK') {
|
||
return res.status(500).json({ error: 'Error fetching distance from Google Maps API' });
|
||
}
|
||
const { distance, duration } = distanceResponse.data.rows[0].elements[0];
|
||
res.json({ distance: distance.text, duration: duration.text });
|
||
} catch (error) {
|
||
console.error('Error during distance calculation:', error);
|
||
res.status(500).json({ error: 'Internal server error', details: error.message });
|
||
}
|
||
});
|
||
|
||
// ONet submission
|
||
app.post('/api/onet/submit_answers', async (req, res) => {
|
||
console.log('POST /api/onet/submit_answers hit');
|
||
const { answers } = req.body;
|
||
if (!answers || answers.length !== 60) {
|
||
console.error('Invalid answers:', answers);
|
||
return res.status(400).json({ error: 'Answers must be 60 chars long.' });
|
||
}
|
||
try {
|
||
const careerUrl = `https://services.onetcenter.org/ws/mnm/interestprofiler/careers?answers=${answers}&start=1&end=1000`;
|
||
const resultsUrl = `https://services.onetcenter.org/ws/mnm/interestprofiler/results?answers=${answers}`;
|
||
|
||
// career suggestions
|
||
const careerResponse = await axios.get(careerUrl, {
|
||
auth: {
|
||
username: process.env.ONET_USERNAME,
|
||
password: process.env.ONET_PASSWORD,
|
||
},
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
// RIASEC
|
||
const resultsResponse = await axios.get(resultsUrl, {
|
||
auth: {
|
||
username: process.env.ONET_USERNAME,
|
||
password: process.env.ONET_PASSWORD,
|
||
},
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
|
||
const careerSuggestions = careerResponse.data.career || [];
|
||
const riaSecScores = resultsResponse.data.result || [];
|
||
|
||
// filter out lower ed
|
||
const filtered = filterHigherEducationCareers(careerSuggestions);
|
||
|
||
const riasecCode = convertToRiasecCode(riaSecScores);
|
||
|
||
const token = req.headers.authorization?.split(' ')[1];
|
||
if (token) {
|
||
try {
|
||
await axios.post('/api/user-profile',
|
||
{
|
||
interest_inventory_answers: answers,
|
||
riasec: riasecCode
|
||
},
|
||
{ headers: { Authorization: `Bearer ${token}` } }
|
||
);
|
||
} catch (err) {
|
||
console.error('Error storing RIASEC in user_profile =>', err.response?.data || err.message);
|
||
// fallback if needed
|
||
}
|
||
}
|
||
|
||
res.status(200).json({
|
||
careers: filtered,
|
||
riaSecScores,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error in ONet API:', error.response?.data || error.message);
|
||
res.status(500).json({
|
||
error: 'Failed to fetch data from ONet',
|
||
details: error.response?.data || error.message,
|
||
});
|
||
}
|
||
});
|
||
function filterHigherEducationCareers(careers) {
|
||
return careers
|
||
.map((c) => {
|
||
const edu = c.education;
|
||
if (!['No formal education', 'High school', 'Some college, no degree'].includes(edu)) {
|
||
return {
|
||
href: c.href,
|
||
fit: c.fit,
|
||
code: c.code,
|
||
title: c.title,
|
||
tags: c.tags,
|
||
};
|
||
}
|
||
return null;
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
function convertToRiasecCode(riaSecScores) {
|
||
// We assume each item has { area, score, description }
|
||
// Sort them by area in R, I, A, S, E, C order or by highest score, whichever you prefer:
|
||
|
||
// Sort by standard R -> I -> A -> S -> E -> C ordering:
|
||
const order = { Realistic: 0, Investigative: 1, Artistic: 2, Social: 3, Enterprising: 4, Conventional: 5 };
|
||
// or you can sort by descending score:
|
||
// const sorted = [...riaSecScores].sort((a, b) => b.score - a.score);
|
||
|
||
// For this example, let's do the standard R -> I -> A -> S -> E -> C
|
||
const sorted = [...riaSecScores].sort((a, b) => order[a.area] - order[b.area]);
|
||
|
||
// Now build the 6-letter code
|
||
// e.g. "RI" + "A" + ...
|
||
// If you want to show tie-breaking or real logic, you can do so
|
||
return sorted.map(item => item.area[0].toUpperCase()).join('');
|
||
// e.g. "RIASEC"
|
||
}
|
||
|
||
// ONet career details
|
||
app.get('/api/onet/career-details/:socCode', async (req, res) => {
|
||
const { socCode } = req.params;
|
||
if (!socCode) {
|
||
return res.status(400).json({ error: 'SOC code is required' });
|
||
}
|
||
try {
|
||
const response = await axios.get(`https://services.onetcenter.org/ws/mnm/careers/${socCode}`, {
|
||
auth: {
|
||
username: process.env.ONET_USERNAME,
|
||
password: process.env.ONET_PASSWORD,
|
||
},
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
res.status(200).json(response.data);
|
||
} catch (err) {
|
||
console.error('Error fetching career details:', err);
|
||
res.status(500).json({ error: 'Failed to fetch career details' });
|
||
}
|
||
});
|
||
|
||
// ONet career description
|
||
app.get('/api/onet/career-description/:socCode', async (req, res) => {
|
||
const { socCode } = req.params;
|
||
if (!socCode) {
|
||
return res.status(400).json({ error: 'SOC Code is required' });
|
||
}
|
||
try {
|
||
const response = await axios.get(`https://services.onetcenter.org/ws/mnm/careers/${socCode}`, {
|
||
auth: {
|
||
username: process.env.ONET_USERNAME,
|
||
password: process.env.ONET_PASSWORD,
|
||
},
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
if (response.data && response.data.title) {
|
||
const { what_they_do, on_the_job } = response.data;
|
||
const tasks = on_the_job?.task || [];
|
||
return res.json({
|
||
description: what_they_do || 'No description available',
|
||
tasks: tasks.length ? tasks : ['No tasks available'],
|
||
});
|
||
}
|
||
return res.status(404).json({ error: 'Career not found for SOC code' });
|
||
} catch (error) {
|
||
console.error('Error in career-description route:', error);
|
||
res.status(500).json({ error: 'Failed to fetch career description' });
|
||
}
|
||
});
|
||
|
||
// CIP route
|
||
app.get('/api/cip/:socCode', (req, res) => {
|
||
const { socCode } = req.params;
|
||
console.log(`Received SOC Code: ${socCode.trim()}`);
|
||
|
||
for (let row of socToCipMapping) {
|
||
const mappedSOC = row['O*NET-SOC 2019 Code']?.trim();
|
||
if (mappedSOC === socCode.trim()) {
|
||
console.log('Found CIP code:', row['2020 CIP Code']);
|
||
return res.json({ cipCode: row['2020 CIP Code'] });
|
||
}
|
||
}
|
||
console.error('SOC code not found in mapping:', socCode);
|
||
res.status(404).json({ error: 'CIP code not found' });
|
||
});
|
||
|
||
/** @aiTool {
|
||
"name": "getSchoolsForCIPs",
|
||
"description": "Return a list of schools whose CIP codes match the supplied prefixes in the given state",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"cipCodes": {
|
||
"type": "string",
|
||
"description": "Comma-separated CIP prefixes, e.g. \"1101,1103\""
|
||
},
|
||
"state": {
|
||
"type": "string",
|
||
"description": "Two-letter state abbreviation, e.g. \"GA\""
|
||
}
|
||
},
|
||
"required": ["cipCodes", "state"]
|
||
},
|
||
"pages": ["EducationalProgramsPage"],
|
||
"write": false
|
||
} */
|
||
|
||
/**************************************************
|
||
* Single schools / tuition / etc. routes
|
||
**************************************************/
|
||
app.get('/api/schools', (req, res) => {
|
||
// 1) Read `cipCodes` from query (comma-separated string)
|
||
const { cipCodes } = req.query;
|
||
|
||
if (!cipCodes) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'cipCodes (comma-separated) and state are required.' });
|
||
}
|
||
|
||
try {
|
||
// 2) Convert `cipCodes` to array => e.g. "1101,1103,1104" => ["1101","1103","1104"]
|
||
const cipArray = cipCodes
|
||
.split(',')
|
||
.map((c) => c.trim())
|
||
.filter(Boolean);
|
||
if (cipArray.length === 0) {
|
||
return res.status(400).json({ error: 'No valid CIP codes were provided.' });
|
||
}
|
||
|
||
// 3) Load your raw schools data
|
||
let schoolsData = [];
|
||
try {
|
||
schoolsData = institutionData;
|
||
} catch (err) {
|
||
console.error('Error parsing institution data:', err.message);
|
||
return res.status(500).json({ error: 'Failed to load schools data.' });
|
||
}
|
||
|
||
// 4) Filter any school whose CIP code matches ANY of the CIP codes in the array
|
||
const filtered = schoolsData.filter((s) => {
|
||
const scip = s['CIPCODE']?.toString().replace('.', '').slice(0, 4);
|
||
return cipArray.some((cip) => scip.startsWith(cip));
|
||
});
|
||
|
||
// 5) (Optional) Deduplicate
|
||
const uniqueMap = new Map();
|
||
for (const school of filtered) {
|
||
const key = school.UNITID || school.INSTNM; // pick your unique field
|
||
if (!uniqueMap.has(key)) {
|
||
uniqueMap.set(key, school);
|
||
}
|
||
}
|
||
const deduped = Array.from(uniqueMap.values());
|
||
|
||
console.log('Unique schools found:', deduped.length);
|
||
res.json(deduped);
|
||
} catch (err) {
|
||
console.error('Error reading Institution data:', err.message);
|
||
res.status(500).json({ error: 'Failed to load schools data.' });
|
||
}
|
||
});
|
||
|
||
/** @aiTool {
|
||
"name": "getTuitionForCIPs",
|
||
"description": "Return in-state / out-state tuition rows for schools matching CIP prefixes in a given state",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"cipCodes": { "type": "string", "description": "Comma-separated prefixes, e.g. \"1101,1103\"" },
|
||
"state": { "type": "string", "description": "Two-letter state code, e.g. \"GA\"" }
|
||
},
|
||
"required": ["cipCodes", "state"]
|
||
},
|
||
"pages": ["EducationalProgramsPage", "LoanRepayment"],
|
||
"write": false
|
||
} */
|
||
|
||
// tuition
|
||
app.get('/api/tuition', (req, res) => {
|
||
const { cipCodes, state } = req.query;
|
||
if (!cipCodes || !state) {
|
||
return res.status(400).json({ error: 'cipCodes and state are required.' });
|
||
}
|
||
|
||
try {
|
||
schoolsData = institutionData;
|
||
|
||
const cipArray = cipCodes
|
||
.split(',')
|
||
.map((c) => c.trim())
|
||
.filter(Boolean);
|
||
if (!cipArray.length) {
|
||
return res.status(400).json({ error: 'No valid CIP codes.' });
|
||
}
|
||
|
||
// Filter logic
|
||
const filtered = schoolsData.filter((school) => {
|
||
const cval = school['CIPCODE']?.toString().replace(/\./g, '').slice(0, 4);
|
||
const sVal = school['State']?.toUpperCase().trim();
|
||
|
||
// Check if cval starts with ANY CIP in cipArray
|
||
const matchesCip = cipArray.some((cip) => cval.startsWith(cip));
|
||
const matchesState = sVal === state.toUpperCase().trim();
|
||
|
||
return matchesCip && matchesState;
|
||
});
|
||
|
||
// Optionally deduplicate by UNITID
|
||
const uniqueMap = new Map();
|
||
for (const school of filtered) {
|
||
const key = school.UNITID || school.INSTNM; // or something else unique
|
||
if (!uniqueMap.has(key)) {
|
||
uniqueMap.set(key, school);
|
||
}
|
||
}
|
||
|
||
const deduped = Array.from(uniqueMap.values());
|
||
console.log('Filtered Tuition Data Count:', deduped.length);
|
||
|
||
res.json(deduped);
|
||
} catch (err) {
|
||
console.error('Error reading tuition data:', err.message);
|
||
res.status(500).json({ error: 'Failed to load tuition data.' });
|
||
}
|
||
});
|
||
|
||
/** @aiTool {
|
||
"name": "getEconomicProjections",
|
||
"description": "Return state and national employment projections for a SOC code",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"socCode": { "type": "string" },
|
||
"state": { "type": "string", "description": "Optional state abbreviation" }
|
||
},
|
||
"required": ["socCode"]
|
||
},
|
||
"pages": ["CareerExplorer"],
|
||
"write": false
|
||
} */
|
||
|
||
/**************************************************
|
||
* SINGLE route for projections from economicproj.json
|
||
**************************************************/
|
||
app.get('/api/projections/:socCode', (req, res) => {
|
||
const { socCode } = req.params;
|
||
const { state } = req.query;
|
||
console.log('Projections request for', socCode, ' state=', state);
|
||
|
||
if (!socCode) {
|
||
return res.status(400).json({ error: 'SOC Code is required.' });
|
||
}
|
||
|
||
// If no ?state=, default to "United States"
|
||
const areaName = state ? state.trim() : 'United States';
|
||
|
||
// Find the row for the requested area
|
||
const rowForState = allProjections.find(
|
||
(row) =>
|
||
row['Occupation Code'] === socCode.trim() &&
|
||
row['Area Name']?.toLowerCase() === areaName.toLowerCase()
|
||
);
|
||
|
||
// Also find the row for "United States"
|
||
const rowForUS = allProjections.find(
|
||
(row) =>
|
||
row['Occupation Code'] === socCode.trim() &&
|
||
row['Area Name']?.toLowerCase() === 'united states'
|
||
);
|
||
|
||
if (!rowForState && !rowForUS) {
|
||
return res
|
||
.status(404)
|
||
.json({ error: 'No projections found for this SOC + area.' });
|
||
}
|
||
|
||
function formatRow(r) {
|
||
if (!r) return null;
|
||
return {
|
||
area: r['Area Name'],
|
||
baseYear: r['Base Year'],
|
||
base: r['Base'],
|
||
projectedYear: r['Projected Year'],
|
||
projection: r['Projection'],
|
||
change: r['Change'],
|
||
percentChange: r['Percent Change'],
|
||
annualOpenings: r['Average Annual Openings'],
|
||
occupationName: r['Occupation Name'],
|
||
};
|
||
}
|
||
|
||
const result = {
|
||
state: formatRow(rowForState),
|
||
national: formatRow(rowForUS),
|
||
};
|
||
|
||
return res.json(result);
|
||
});
|
||
|
||
/** @aiTool {
|
||
"name": "getSalaryData",
|
||
"description": "Return residential area and national salary percentiles for a SOC code",
|
||
"parameters": {
|
||
"type": "object",
|
||
"properties": {
|
||
"socCode": { "type": "string" },
|
||
"area": { "type": "string", "description": "User's residential area" }
|
||
},
|
||
"required": ["socCode"]
|
||
},
|
||
"pages": ["CareerExplorer"],
|
||
"write": false
|
||
} */
|
||
|
||
/**************************************************
|
||
* Salary route
|
||
**************************************************/
|
||
app.get('/api/salary', async (req, res) => {
|
||
const { socCode, area } = req.query;
|
||
console.log('Received /api/salary request:', { socCode, area });
|
||
if (!socCode) {
|
||
return res.status(400).json({ error: 'SOC Code is required' });
|
||
}
|
||
|
||
const regionalQuery = `
|
||
SELECT A_PCT10 AS regional_PCT10,
|
||
A_PCT25 AS regional_PCT25,
|
||
A_MEDIAN AS regional_MEDIAN,
|
||
A_PCT75 AS regional_PCT75,
|
||
A_PCT90 AS regional_PCT90
|
||
FROM salary_data
|
||
WHERE OCC_CODE = ? AND AREA_TITLE = ?
|
||
`;
|
||
const nationalQuery = `
|
||
SELECT A_PCT10 AS national_PCT10,
|
||
A_PCT25 AS national_PCT25,
|
||
A_MEDIAN AS national_MEDIAN,
|
||
A_PCT75 AS national_PCT75,
|
||
A_PCT90 AS national_PCT90
|
||
FROM salary_data
|
||
WHERE OCC_CODE = ? AND AREA_TITLE = 'U.S.'
|
||
`;
|
||
|
||
try {
|
||
let regionalRow = null;
|
||
let nationalRow = null;
|
||
|
||
if (area) {
|
||
regionalRow = await db.get(regionalQuery, [socCode, area]);
|
||
}
|
||
nationalRow = await db.get(nationalQuery, [socCode]);
|
||
|
||
if (!regionalRow && !nationalRow) {
|
||
console.log('No salary data found for:', { socCode, area });
|
||
return res.status(404).json({ error: 'No salary data found' });
|
||
}
|
||
const salaryData = {
|
||
regional: regionalRow || {},
|
||
national: nationalRow || {},
|
||
};
|
||
console.log('Salary data retrieved:', salaryData);
|
||
res.json(salaryData);
|
||
} catch (error) {
|
||
console.error('Error executing salary query:', error.message);
|
||
res.status(500).json({ error: 'Failed to fetch salary data' });
|
||
}
|
||
});
|
||
|
||
/**************************************************
|
||
* job-zones route
|
||
**************************************************/
|
||
app.post('/api/job-zones', async (req, res) => {
|
||
const { socCodes } = req.body;
|
||
if (!socCodes || !Array.isArray(socCodes) || socCodes.length === 0) {
|
||
return res.status(400).json({ error: 'SOC Codes are required.' });
|
||
}
|
||
try {
|
||
// Format them
|
||
const formattedSocCodes = socCodes.map((code) => {
|
||
let cleaned = code.trim().replace(/\./g, '');
|
||
if (!cleaned.includes('-') && cleaned.length === 6) {
|
||
cleaned = cleaned.slice(0, 2) + '-' + cleaned.slice(2);
|
||
}
|
||
return cleaned.slice(0, 7);
|
||
});
|
||
const placeholders = formattedSocCodes.map(() => '?').join(',');
|
||
const q = `
|
||
SELECT OCC_CODE,
|
||
JOB_ZONE,
|
||
A_MEDIAN,
|
||
A_PCT10,
|
||
A_PCT25,
|
||
A_PCT75
|
||
FROM salary_data
|
||
WHERE OCC_CODE IN (${placeholders})
|
||
`;
|
||
const rows = await db.all(q, formattedSocCodes);
|
||
console.log('Salary Data Query Results:', rows);
|
||
|
||
const jobZoneMapping = rows.reduce((acc, row) => {
|
||
const isMissing = [row.A_MEDIAN, row.A_PCT10, row.A_PCT25, row.A_PCT75].some(
|
||
(v) => !v || v === '#' || v === '*'
|
||
);
|
||
acc[row.OCC_CODE] = {
|
||
job_zone: row.JOB_ZONE,
|
||
limited_data: isMissing ? 1 : 0,
|
||
};
|
||
return acc;
|
||
}, {});
|
||
console.log('Job Zone & Limited Data:', jobZoneMapping);
|
||
res.json(jobZoneMapping);
|
||
} catch (error) {
|
||
console.error('Error fetching job zones:', error);
|
||
res.status(500).json({ error: 'Failed to fetch job zones.' });
|
||
}
|
||
});
|
||
|
||
/**************************************************
|
||
* O*NET Skills route
|
||
**************************************************/
|
||
app.get('/api/skills/:socCode', async (req, res) => {
|
||
const { socCode } = req.params;
|
||
if (!socCode) {
|
||
return res.status(400).json({ error: 'SOC code is required' });
|
||
}
|
||
|
||
console.log(`Fetching O*NET skills for SOC Code: ${socCode}`);
|
||
|
||
try {
|
||
// 1) Build the O*NET API URL
|
||
const onetUrl = `https://services.onetcenter.org/ws/mnm/careers/${socCode}/skills`;
|
||
|
||
// 2) Call O*NET with Basic Auth
|
||
const response = await axios.get(onetUrl, {
|
||
auth: {
|
||
username: process.env.ONET_USERNAME,
|
||
password: process.env.ONET_PASSWORD,
|
||
},
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
|
||
const data = response.data || {};
|
||
|
||
const groups = data.group || [];
|
||
const skills = [];
|
||
|
||
// Flatten out the group[].element[] into a single skills array
|
||
groups.forEach((groupItem) => {
|
||
const groupName = groupItem?.title?.name || 'Unknown Group';
|
||
if (Array.isArray(groupItem.element)) {
|
||
groupItem.element.forEach((elem) => {
|
||
skills.push({
|
||
groupName,
|
||
skillId: elem.id,
|
||
skillName: elem.name,
|
||
});
|
||
});
|
||
}
|
||
});
|
||
|
||
res.json({ skills });
|
||
} catch (error) {
|
||
console.error('Error fetching O*NET skills:', error.message);
|
||
|
||
if (error.response) {
|
||
console.error('O*NET error status:', error.response.status);
|
||
console.error('O*NET error data:', error.response.data);
|
||
} else if (error.request) {
|
||
console.error('No response received from O*NET.');
|
||
console.error('Axios error.request:', error.request);
|
||
} else {
|
||
console.error('Request setup error:', error.message);
|
||
}
|
||
|
||
return res.status(500).json({ error: 'Failed to fetch O*NET skills' });
|
||
}
|
||
});
|
||
|
||
/**************************************************
|
||
* user-profile by ID route
|
||
**************************************************/
|
||
app.get('/api/user-profile/:id', (req, res) => {
|
||
const { id } = req.params;
|
||
if (!id) return res.status(400).json({ error: 'Profile ID is required' });
|
||
|
||
const query = `SELECT area, zipcode FROM user_profile WHERE id = ?`;
|
||
userProfileDb.get(query, [id], (err, row) => {
|
||
if (err) {
|
||
console.error('Error fetching user profile:', err.message);
|
||
return res.status(500).json({ error: 'Failed to fetch user profile' });
|
||
}
|
||
if (!row) {
|
||
return res.status(404).json({ error: 'Profile not found' });
|
||
}
|
||
res.json({ area: row.area, zipcode: row.zipcode });
|
||
});
|
||
});
|
||
|
||
|
||
/***************************************************
|
||
* AI RISK ASSESSMENT ENDPOINT READ
|
||
****************************************************/
|
||
app.get('/api/ai-risk/:socCode', async (req, res) => {
|
||
const { socCode } = req.params;
|
||
try {
|
||
const row = await getRiskAnalysisFromDB(socCode);
|
||
if (!row) {
|
||
return res.status(404).json({ error: 'Not found' });
|
||
}
|
||
// Return full data or partial, up to you:
|
||
res.json({
|
||
socCode: row.soc_code,
|
||
careerName: row.career_name,
|
||
jobDescription: row.job_description,
|
||
tasks: row.tasks,
|
||
riskLevel: row.risk_level,
|
||
reasoning: row.reasoning,
|
||
created_at: row.created_at,
|
||
});
|
||
} catch (err) {
|
||
console.error('Error fetching AI risk:', err);
|
||
res.status(500).json({ error: 'Internal server error' });
|
||
}
|
||
});
|
||
|
||
/***************************************************
|
||
* AI RISK ASSESSMENT ENDPOINT WRITE
|
||
****************************************************/
|
||
app.post('/api/ai-risk', async (req, res) => {
|
||
try {
|
||
const {
|
||
socCode,
|
||
careerName,
|
||
jobDescription,
|
||
tasks,
|
||
riskLevel,
|
||
reasoning,
|
||
} = req.body;
|
||
|
||
if (!socCode) {
|
||
return res.status(400).json({ error: 'socCode is required' });
|
||
}
|
||
|
||
// Store / upsert row
|
||
await storeRiskAnalysisInDB({
|
||
socCode,
|
||
careerName,
|
||
jobDescription,
|
||
tasks,
|
||
riskLevel,
|
||
vectorSearch,
|
||
reasoning,
|
||
});
|
||
|
||
res.status(201).json({ message: 'AI Risk Analysis stored successfully' });
|
||
} catch (err) {
|
||
console.error('Error storing AI risk data:', err);
|
||
res.status(500).json({ error: 'Failed to store AI risk data.' });
|
||
}
|
||
});
|
||
|
||
chatFreeEndpoint(app, {
|
||
openai,
|
||
authenticateUser, // or omit if you don’t need auth yet
|
||
chatLimiter,
|
||
userProfileDb
|
||
});
|
||
|
||
/**************************************************
|
||
* Start the Express server
|
||
**************************************************/
|
||
app.listen(PORT, () => {
|
||
console.log(`Server running on port ${PORT}`);
|
||
});
|