dev1/backend/server2.js

1030 lines
33 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**************************************************
* server2.js - Reverted to SQLite version
**************************************************/
import express from 'express';
import axios from 'axios';
import cors from 'cors';
import helmet from 'helmet'; // For HTTP security headers
import dotenv from 'dotenv';
import xlsx from 'xlsx'; // Keep for CIP->SOC mapping only
import path from 'path';
import { fileURLToPath } from 'url';
import { open } from 'sqlite';
import sqlite3 from 'sqlite3';
import fs from 'fs';
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 } from './shared/crypto/encryption.js';
// --- Basic file init ---
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootPath = path.resolve(__dirname, '..'); // Up one level
const env = process.env.NODE_ENV?.trim() || 'development';
const envPath = path.resolve(rootPath, `.env.${env}`);
dotenv.config({ path: envPath }); // Load .env
const ROOT_DIR = path.resolve(__dirname, '..'); // repo root
const PUBLIC_DIR = path.join(ROOT_DIR, 'public'); // static json files
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
});
// Institution data
const institutionData = JSON.parse(fs.readFileSync(INSTITUTION_DATA_PATH, 'utf8'));
await initEncryption();
// Create Express app
const app = express();
const PORT = process.env.SERVER2_PORT || 5001;
// at top of backend/server.js (do once per server codebase)
app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
/**************************************************
* DB connections
**************************************************/
let db;
let userProfileDb;
async function initDatabases() {
try {
db = 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); // let Docker restart the service
}
}
await initDatabases();
/* ──────────────────────────────────────────────────────────────
* 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 dont need auth yet
chatLimiter,
userProfileDb
});
/**************************************************
* Start the Express server
**************************************************/
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});