3180 lines
96 KiB
JavaScript
3180 lines
96 KiB
JavaScript
//
|
||
// server3.js - MySQL Version
|
||
//
|
||
import express from 'express';
|
||
import cors from 'cors';
|
||
import helmet from 'helmet';
|
||
import dotenv from 'dotenv';
|
||
import path from 'path';
|
||
import fs from 'fs/promises';
|
||
import multer from 'multer';
|
||
import fetch from "node-fetch";
|
||
import mammoth from 'mammoth';
|
||
import { fileURLToPath } from 'url';
|
||
import jwt from 'jsonwebtoken';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import pkg from 'pdfjs-dist';
|
||
import mysql from 'mysql2/promise'; // <-- MySQL instead of SQLite
|
||
import OpenAI from 'openai';
|
||
import Fuse from 'fuse.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 file
|
||
|
||
const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
|
||
|
||
const app = express();
|
||
const PORT = process.env.PREMIUM_PORT || 5002;
|
||
const { getDocument } = pkg;
|
||
|
||
function internalFetch(req, url, opts = {}) {
|
||
return fetch(url, {
|
||
...opts,
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: req.headers?.authorization || "", // tolerate undefined
|
||
...(opts.headers || {})
|
||
}
|
||
});
|
||
}
|
||
|
||
|
||
// 1) Create a MySQL pool using your environment variables
|
||
const pool = mysql.createPool({
|
||
host: process.env.DB_HOST || 'localhost',
|
||
user: process.env.DB_USER || 'root',
|
||
password: process.env.DB_PASSWORD || '',
|
||
database: process.env.DB_NAME || 'user_profile_db',
|
||
waitForConnections: true,
|
||
connectionLimit: 10,
|
||
queueLimit: 0
|
||
});
|
||
|
||
// 2) Basic middlewares
|
||
app.use(helmet());
|
||
app.use(express.json({ limit: '5mb' }));
|
||
|
||
const allowedOrigins = ['https://dev1.aptivaai.com'];
|
||
app.use(cors({ origin: allowedOrigins, credentials: true }));
|
||
|
||
// 3) Authentication middleware
|
||
const authenticatePremiumUser = (req, res, next) => {
|
||
const token = req.headers.authorization?.split(' ')[1];
|
||
if (!token) {
|
||
return res.status(401).json({ error: 'Premium authorization required' });
|
||
}
|
||
|
||
try {
|
||
const SECRET_KEY = process.env.SECRET_KEY || 'supersecurekey';
|
||
const { id } = jwt.verify(token, SECRET_KEY);
|
||
req.id = id; // store user ID in request
|
||
next();
|
||
} catch (error) {
|
||
return res.status(403).json({ error: 'Invalid or expired token' });
|
||
}
|
||
};
|
||
|
||
/* ------------------------------------------------------------------
|
||
CAREER PROFILE ENDPOINTS
|
||
------------------------------------------------------------------ */
|
||
|
||
// GET the latest selected career profile
|
||
app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const sql = `
|
||
SELECT
|
||
*,
|
||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
|
||
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
|
||
FROM career_profiles
|
||
WHERE user_id = ?
|
||
ORDER BY start_date DESC
|
||
LIMIT 1
|
||
`;
|
||
const [rows] = await pool.query(sql, [req.id]);
|
||
res.json(rows[0] || {});
|
||
} catch (error) {
|
||
console.error('Error fetching latest career profile:', error);
|
||
res.status(500).json({ error: 'Failed to fetch latest career profile' });
|
||
}
|
||
});
|
||
|
||
// GET all career profiles for the user
|
||
app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const sql = `
|
||
SELECT
|
||
*,
|
||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
|
||
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
|
||
FROM career_profiles
|
||
WHERE user_id = ?
|
||
ORDER BY start_date ASC
|
||
`;
|
||
const [rows] = await pool.query(sql, [req.id]);
|
||
res.json({ careerProfiles: rows });
|
||
} catch (error) {
|
||
console.error('Error fetching career profiles:', error);
|
||
res.status(500).json({ error: 'Failed to fetch career profiles' });
|
||
}
|
||
});
|
||
|
||
// GET a single career profile (scenario) by ID
|
||
app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, async (req, res) => {
|
||
const { careerProfileId } = req.params;
|
||
try {
|
||
const sql = `
|
||
SELECT
|
||
*,
|
||
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
|
||
DATE_FORMAT(projected_end_date, '%Y-%m-%d') AS projected_end_date
|
||
FROM career_profiles
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
LIMIT 1
|
||
`;
|
||
const [rows] = await pool.query(sql, [careerProfileId, req.id]);
|
||
|
||
if (!rows[0]) {
|
||
return res.status(404).json({ error: 'Career profile not found or not yours.' });
|
||
}
|
||
res.json(rows[0]);
|
||
} catch (error) {
|
||
console.error('Error fetching single career profile:', error);
|
||
res.status(500).json({ error: 'Failed to fetch career profile by ID.' });
|
||
}
|
||
});
|
||
|
||
|
||
// POST a new career profile (upsert)
|
||
app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res) => {
|
||
const {
|
||
scenario_title,
|
||
career_name,
|
||
status,
|
||
start_date,
|
||
projected_end_date,
|
||
college_enrollment_status,
|
||
currently_working,
|
||
// The new field:
|
||
career_goals,
|
||
|
||
// planned fields
|
||
planned_monthly_expenses,
|
||
planned_monthly_debt_payments,
|
||
planned_monthly_retirement_contribution,
|
||
planned_monthly_emergency_contribution,
|
||
planned_surplus_emergency_pct,
|
||
planned_surplus_retirement_pct,
|
||
planned_additional_income
|
||
} = req.body;
|
||
|
||
if (!career_name) {
|
||
return res.status(400).json({ error: 'career_name is required.' });
|
||
}
|
||
|
||
try {
|
||
const finalId = req.body.id || uuidv4();
|
||
|
||
// 1) Insert includes career_goals
|
||
const sql = `
|
||
INSERT INTO career_profiles (
|
||
id,
|
||
user_id,
|
||
scenario_title,
|
||
career_name,
|
||
status,
|
||
start_date,
|
||
projected_end_date,
|
||
college_enrollment_status,
|
||
currently_working,
|
||
career_goals, -- ADD THIS
|
||
planned_monthly_expenses,
|
||
planned_monthly_debt_payments,
|
||
planned_monthly_retirement_contribution,
|
||
planned_monthly_emergency_contribution,
|
||
planned_surplus_emergency_pct,
|
||
planned_surplus_retirement_pct,
|
||
planned_additional_income
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
ON DUPLICATE KEY UPDATE
|
||
status = VALUES(status),
|
||
start_date = VALUES(start_date),
|
||
projected_end_date = VALUES(projected_end_date),
|
||
college_enrollment_status = VALUES(college_enrollment_status),
|
||
currently_working = VALUES(currently_working),
|
||
career_goals = VALUES(career_goals), -- ADD THIS
|
||
planned_monthly_expenses = VALUES(planned_monthly_expenses),
|
||
planned_monthly_debt_payments = VALUES(planned_monthly_debt_payments),
|
||
planned_monthly_retirement_contribution = VALUES(planned_monthly_retirement_contribution),
|
||
planned_monthly_emergency_contribution = VALUES(planned_monthly_emergency_contribution),
|
||
planned_surplus_emergency_pct = VALUES(planned_surplus_emergency_pct),
|
||
planned_surplus_retirement_pct = VALUES(planned_surplus_retirement_pct),
|
||
planned_additional_income = VALUES(planned_additional_income),
|
||
updated_at = CURRENT_TIMESTAMP
|
||
`;
|
||
|
||
await pool.query(sql, [
|
||
finalId,
|
||
req.id,
|
||
scenario_title || null,
|
||
career_name,
|
||
status || 'planned',
|
||
start_date || null,
|
||
projected_end_date || null,
|
||
college_enrollment_status || null,
|
||
currently_working || null,
|
||
career_goals || null, // pass career_goals here
|
||
planned_monthly_expenses ?? null,
|
||
planned_monthly_debt_payments ?? null,
|
||
planned_monthly_retirement_contribution ?? null,
|
||
planned_monthly_emergency_contribution ?? null,
|
||
planned_surplus_emergency_pct ?? null,
|
||
planned_surplus_retirement_pct ?? null,
|
||
planned_additional_income ?? null
|
||
]);
|
||
|
||
// re-fetch to confirm ID
|
||
const [rows] = await pool.query(
|
||
`SELECT id
|
||
FROM career_profiles
|
||
WHERE id = ?`,
|
||
[finalId]
|
||
);
|
||
|
||
return res.status(200).json({
|
||
message: 'Career profile upserted.',
|
||
career_profile_id: finalId
|
||
});
|
||
} catch (error) {
|
||
console.error('Error upserting career profile:', error);
|
||
res.status(500).json({ error: 'Failed to upsert career profile.' });
|
||
}
|
||
});
|
||
|
||
// server3.js (add near the other career-profile routes)
|
||
app.put('/api/premium/career-profile/:id/goals', authenticatePremiumUser, async (req, res) => {
|
||
const { id } = req.params;
|
||
const { career_goals } = req.body;
|
||
|
||
// simple ownership check
|
||
const [rows] = await pool.query('SELECT user_id FROM career_profiles WHERE id=?', [id]);
|
||
if (!rows[0] || rows[0].user_id !== req.id) {
|
||
return res.status(403).json({ error: 'Not your profile.' });
|
||
}
|
||
await pool.query('UPDATE career_profiles SET career_goals=? WHERE id=?', [career_goals, id]);
|
||
res.json({ career_goals });
|
||
});
|
||
|
||
// DELETE a career profile (scenario) by ID
|
||
app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, async (req, res) => {
|
||
const { careerProfileId } = req.params;
|
||
try {
|
||
// confirm ownership
|
||
const [rows] = await pool.query(`
|
||
SELECT id
|
||
FROM career_profiles
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [careerProfileId, req.id]);
|
||
|
||
if (!rows[0]) {
|
||
return res.status(404).json({ error: 'Career profile not found or not yours.' });
|
||
}
|
||
|
||
// delete college_profiles
|
||
await pool.query(`
|
||
DELETE FROM college_profiles
|
||
WHERE user_id = ?
|
||
AND career_profile_id = ?
|
||
`, [req.id, careerProfileId]);
|
||
|
||
// delete scenario’s milestones + tasks + impacts
|
||
const [mils] = await pool.query(`
|
||
SELECT id
|
||
FROM milestones
|
||
WHERE user_id = ?
|
||
AND career_profile_id = ?
|
||
`, [req.id, careerProfileId]);
|
||
const milestoneIds = mils.map(m => m.id);
|
||
|
||
if (milestoneIds.length > 0) {
|
||
const placeholders = milestoneIds.map(() => '?').join(',');
|
||
|
||
// tasks
|
||
await pool.query(`
|
||
DELETE FROM tasks
|
||
WHERE milestone_id IN (${placeholders})
|
||
`, milestoneIds);
|
||
|
||
// impacts
|
||
await pool.query(`
|
||
DELETE FROM milestone_impacts
|
||
WHERE milestone_id IN (${placeholders})
|
||
`, milestoneIds);
|
||
|
||
// milestones
|
||
await pool.query(`
|
||
DELETE FROM milestones
|
||
WHERE id IN (${placeholders})
|
||
`, milestoneIds);
|
||
}
|
||
|
||
// delete the career_profiles row
|
||
await pool.query(`
|
||
DELETE FROM career_profiles
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [careerProfileId, req.id]);
|
||
|
||
res.json({ message: 'Career profile and related data successfully deleted.' });
|
||
} catch (error) {
|
||
console.error('Error deleting career profile:', error);
|
||
res.status(500).json({ error: 'Failed to delete career profile.' });
|
||
}
|
||
});
|
||
|
||
/***************************************************
|
||
AI - NEXT STEPS ENDPOINT (with date constraints,
|
||
ignoring scenarioRow.start_date)
|
||
****************************************************/
|
||
app.post('/api/premium/ai/next-steps', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
// 1) Gather user data from request
|
||
const {
|
||
userProfile = {},
|
||
scenarioRow = {},
|
||
financialProfile = {},
|
||
collegeProfile = {},
|
||
previouslyUsedTitles = []
|
||
} = req.body;
|
||
|
||
// 2) Build a summary for ChatGPT
|
||
// (We'll ignore scenarioRow.start_date in the prompt)
|
||
const summaryText = buildUserSummary({
|
||
userProfile,
|
||
scenarioRow,
|
||
financialProfile,
|
||
collegeProfile
|
||
});
|
||
|
||
let avoidSection = '';
|
||
if (previouslyUsedTitles.length > 0) {
|
||
avoidSection = `\nDO NOT repeat the following milestone titles:\n${previouslyUsedTitles
|
||
.map((t) => `- ${t}`)
|
||
.join('\n')}\n`;
|
||
}
|
||
|
||
// 3) Dynamically compute "today's" date and future cutoffs
|
||
const now = new Date();
|
||
const isoToday = now.toISOString().slice(0, 10); // e.g. "2025-06-01"
|
||
|
||
// short-term = within 6 months
|
||
const shortTermLimit = new Date(now);
|
||
shortTermLimit.setMonth(shortTermLimit.getMonth() + 6);
|
||
const isoShortTermLimit = shortTermLimit.toISOString().slice(0, 10);
|
||
|
||
// long-term = 1-3 years
|
||
const oneYearFromNow = new Date(now);
|
||
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
|
||
const isoOneYearFromNow = oneYearFromNow.toISOString().slice(0, 10);
|
||
|
||
const threeYearsFromNow = new Date(now);
|
||
threeYearsFromNow.setFullYear(threeYearsFromNow.getFullYear() + 3);
|
||
const isoThreeYearsFromNow = threeYearsFromNow.toISOString().slice(0, 10).slice(0, 10);
|
||
|
||
// 4) Construct ChatGPT messages
|
||
const messages = [
|
||
{
|
||
role: 'system',
|
||
content: `
|
||
You are an expert career & financial coach.
|
||
Today's date: ${isoToday}.
|
||
Short-term means any date up to ${isoShortTermLimit} (within 6 months).
|
||
Long-term means a date between ${isoOneYearFromNow} and ${isoThreeYearsFromNow} (1-3 years).
|
||
All milestone dates must be strictly >= ${isoToday}. Titles must be <= 5 words.
|
||
|
||
IMPORTANT RESTRICTIONS:
|
||
- NEVER suggest specific investments in cryptocurrency, stocks, or other speculative financial instruments.
|
||
- NEVER provide specific investment advice without appropriate risk disclosures.
|
||
- NEVER provide legal, medical, or psychological advice.
|
||
- ALWAYS promote responsible and low-risk financial planning strategies.
|
||
- Emphasize skills enhancement, networking, and education as primary pathways to financial success.
|
||
|
||
Respond ONLY in the requested JSON format.`
|
||
},
|
||
{
|
||
role: 'user',
|
||
content: `
|
||
Here is the user's current situation:
|
||
${summaryText}
|
||
|
||
Please provide exactly 2 short-term (within 6 months) and 1 long-term (1–3 years) milestones. Avoid any previously suggested milestones.
|
||
Each milestone must have:
|
||
- "title" (up to 5 words)
|
||
- "date" in YYYY-MM-DD format (>= ${isoToday})
|
||
- "description" (1-2 sentences)
|
||
|
||
${avoidSection}
|
||
|
||
Return ONLY a JSON array, no extra text:
|
||
|
||
[
|
||
{
|
||
"title": "string",
|
||
"date": "YYYY-MM-DD",
|
||
"description": "string"
|
||
},
|
||
...
|
||
]`
|
||
}
|
||
];
|
||
|
||
// 5) Call OpenAI (ignoring scenarioRow.start_date for date logic)
|
||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||
const completion = await openai.chat.completions.create({
|
||
model: 'gpt-3.5-turbo', // or 'gpt-4'
|
||
messages,
|
||
temperature: 0.7,
|
||
max_tokens: 600
|
||
});
|
||
|
||
// 6) Extract raw text
|
||
const aiAdvice = completion?.choices?.[0]?.message?.content?.trim() || 'No response';
|
||
|
||
res.json({ recommendations: aiAdvice });
|
||
} catch (err) {
|
||
console.error('Error in /api/premium/ai/next-steps =>', err);
|
||
res.status(500).json({ error: 'Failed to get AI next steps.' });
|
||
}
|
||
});
|
||
|
||
/**
|
||
* Helper that converts user data into a concise text summary.
|
||
* This can still mention scenarioRow, but we do NOT feed
|
||
* scenarioRow.start_date to ChatGPT for future date calculations.
|
||
*/
|
||
function buildUserSummary({
|
||
userProfile = {},
|
||
scenarioRow = {},
|
||
financialProfile = {},
|
||
collegeProfile = {},
|
||
aiRisk = null
|
||
}) {
|
||
const location = `${userProfile.state || 'Unknown State'}, ${userProfile.area || 'N/A'}`;
|
||
const careerName = scenarioRow.career_name || 'Unknown';
|
||
const careerGoals = scenarioRow.career_goals || 'No goals specified';
|
||
const status = scenarioRow.status || 'planned';
|
||
const currentlyWorking = scenarioRow.currently_working || 'no';
|
||
|
||
const currentSalary = financialProfile.current_salary || 0;
|
||
const monthlyExpenses = financialProfile.monthly_expenses || 0;
|
||
const monthlyDebt = financialProfile.monthly_debt_payments || 0;
|
||
const retirementSavings = financialProfile.retirement_savings || 0;
|
||
const emergencyFund = financialProfile.emergency_fund || 0;
|
||
|
||
let riskText = '';
|
||
if (aiRisk?.riskLevel) {
|
||
riskText = `
|
||
AI Automation Risk: ${aiRisk.riskLevel}
|
||
Reasoning: ${aiRisk.reasoning}`;
|
||
}
|
||
|
||
return `
|
||
User Location: ${location}
|
||
Career Name: ${careerName}
|
||
Career Goals: ${careerGoals}
|
||
Career Status: ${status}
|
||
Currently Working: ${currentlyWorking}
|
||
|
||
Financial:
|
||
- Salary: \$${currentSalary}
|
||
- Monthly Expenses: \$${monthlyExpenses}
|
||
- Monthly Debt: \$${monthlyDebt}
|
||
- Retirement Savings: \$${retirementSavings}
|
||
- Emergency Fund: \$${emergencyFund}
|
||
|
||
${riskText}
|
||
`.trim();
|
||
}
|
||
|
||
// Example: ai/chat with correct milestone-saving logic
|
||
// At the top of server3.js, leave your imports and setup as-is
|
||
// (No need to import 'pluralize' if we're no longer using it!)
|
||
|
||
app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const {
|
||
userProfile = {},
|
||
scenarioRow = {},
|
||
financialProfile = {},
|
||
collegeProfile = {},
|
||
chatHistory = []
|
||
} = req.body;
|
||
|
||
let existingTitles = [];
|
||
try {
|
||
const [rows] = await pool.query(
|
||
`SELECT title, DATE_FORMAT(date,'%Y-%m-%d') AS d
|
||
FROM milestones
|
||
WHERE user_id = ? AND career_profile_id = ?`,
|
||
[req.id, scenarioRow.id]
|
||
);
|
||
existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`);
|
||
} catch (e) {
|
||
console.error("Could not fetch existing milestones =>", e);
|
||
}
|
||
// ------------------------------------------------
|
||
// 1. Helper Functions
|
||
// ------------------------------------------------
|
||
|
||
// A. Build a "where you are now" vs. "where you want to go" message
|
||
// with phrasing that works for plural career names.
|
||
function buildStatusSituationMessage(status, situation, careerName) {
|
||
// For example: careerName = "Blockchain Engineers"
|
||
const sStatus = (status || "").toLowerCase();
|
||
const sSituation = (situation || "").toLowerCase();
|
||
|
||
// Intro / "Now" part
|
||
let nowPart = "";
|
||
switch (sStatus) {
|
||
case "planned":
|
||
nowPart = `Hi! It sounds like you're looking ahead to potential opportunities in ${careerName}.`;
|
||
break;
|
||
case "current":
|
||
nowPart = `Hi! It looks like you're currently involved in ${careerName}.`;
|
||
break;
|
||
case "exploring":
|
||
nowPart = `Hi! You're exploring how ${careerName} might fit your plans.`;
|
||
break;
|
||
default:
|
||
nowPart = `Hi! I'm not fully sure about your current involvement with ${careerName}, but I'd love to learn more.`;
|
||
break;
|
||
}
|
||
|
||
// Next / "Where you're going" part
|
||
let nextPart = "";
|
||
switch (sSituation) {
|
||
case "planning":
|
||
nextPart = `You're aiming to clarify your strategy for moving into a role within ${careerName}.`;
|
||
break;
|
||
case "preparing":
|
||
nextPart = `You're actively developing the skills you need for future opportunities in ${careerName}.`;
|
||
break;
|
||
case "enhancing":
|
||
nextPart = `You'd like to deepen or broaden your responsibilities within ${careerName}.`;
|
||
break;
|
||
case "retirement":
|
||
nextPart = `You're considering how to transition toward retirement from ${careerName}.`;
|
||
break;
|
||
default:
|
||
nextPart = `I'm not entirely sure what your next steps might be regarding ${careerName}, but we'll figure it out together.`;
|
||
break;
|
||
}
|
||
|
||
const combinedDescription = `${nowPart} ${nextPart}`.trim();
|
||
|
||
// Friendly note - feel free to tweak the wording
|
||
const friendlyNote = `
|
||
Feel free to use AptivaAI however it best suits you—there’s no "wrong" answer.
|
||
It doesn’t matter so much where you've been; it's about where you want to go from here.
|
||
We can refine details any time or jump straight to what you’re most eager to explore right now.
|
||
|
||
If you complete the Interest Inventory, I’ll be able to offer more targeted suggestions based on your interests.
|
||
|
||
I'm here to support you with personalized coaching—what would you like to focus on next?
|
||
`.trim();
|
||
|
||
return `${combinedDescription}\n\n${friendlyNote}`;
|
||
}
|
||
|
||
// B. Build a user summary that references all available info (unchanged from your code)
|
||
function buildUserSummary({
|
||
userProfile = {},
|
||
scenarioRow = {},
|
||
financialProfile = {},
|
||
collegeProfile = {},
|
||
aiRisk = null,
|
||
salaryAnalysis = null,
|
||
economicProjections = null
|
||
}) {
|
||
// 1) USER PROFILE
|
||
const firstName = userProfile.firstname || "N/A";
|
||
const lastName = userProfile.lastname || "N/A";
|
||
const fullName = `${firstName} ${lastName}`;
|
||
const username = userProfile.username || "N/A";
|
||
const location = userProfile.area || userProfile.state || "Unknown Region";
|
||
// userProfile.career_situation might be "enhancing", "preparing", etc.
|
||
const careerSituation = userProfile.career_situation || "Not provided";
|
||
|
||
// RIASEC
|
||
let riasecText = "None";
|
||
if (userProfile.riasec_scores) {
|
||
try {
|
||
const rScores = JSON.parse(userProfile.riasec_scores);
|
||
// { "R":23,"I":25,"A":23,"S":16,"E":15,"C":22 }
|
||
riasecText = `
|
||
(R) Realistic: ${rScores.R}
|
||
(I) Investigative: ${rScores.I}
|
||
(A) Artistic: ${rScores.A}
|
||
(S) Social: ${rScores.S}
|
||
(E) Enterprising: ${rScores.E}
|
||
(C) Conventional: ${rScores.C}
|
||
`.trim();
|
||
} catch(e) {
|
||
console.error("Error parsing RIASEC JSON =>", e);
|
||
}
|
||
}
|
||
|
||
// Possibly parse "career_priorities" if you need them
|
||
let careerPriorities = "Not provided";
|
||
if (userProfile.career_priorities) {
|
||
// e.g. "career_priorities": "{\"interests\":\"Somewhat important\",\"meaning\":\"Somewhat important\",\"stability\":\"Very important\", ...}"
|
||
try {
|
||
const cP = JSON.parse(userProfile.career_priorities);
|
||
// Build a bullet string
|
||
careerPriorities = Object.entries(cP).map(([k,v]) => `- ${k}: ${v}`).join("\n");
|
||
} catch(e) {
|
||
console.error("Error parsing career_priorities =>", e);
|
||
}
|
||
}
|
||
|
||
// 2) CAREER SCENARIO
|
||
// scenarioRow might have career_name, job_description, tasks
|
||
// but you said sometimes you store them in scenarioRow or pass them in a separate param
|
||
const careerName = scenarioRow.career_name || "No career selected";
|
||
const socCode = scenarioRow.soc_code || "N/A";
|
||
const jobDescription = scenarioRow.job_description || "No jobDescription info";
|
||
// scenarioRow.tasks might be an array
|
||
const tasksList = Array.isArray(scenarioRow.tasks) && scenarioRow.tasks.length
|
||
? scenarioRow.tasks.join(", ")
|
||
: "No tasks info";
|
||
|
||
// 3) FINANCIAL PROFILE
|
||
// your actual JSON uses e.g. "current_salary", "additional_income"
|
||
const currentSalary = financialProfile.current_salary || 0;
|
||
const additionalIncome = financialProfile.additional_income || 0;
|
||
const monthlyExpenses = financialProfile.monthly_expenses || 0;
|
||
const monthlyDebt = financialProfile.monthly_debt_payments || 0;
|
||
const retirementSavings = financialProfile.retirement_savings || 0;
|
||
const emergencyFund = financialProfile.emergency_fund || 0;
|
||
|
||
// 4) COLLEGE PROFILE
|
||
// from your JSON:
|
||
const selectedProgram = collegeProfile.selected_program || "N/A";
|
||
const enrollmentStatus = collegeProfile.college_enrollment_status || "Not enrolled";
|
||
const creditHoursCompleted = parseFloat(collegeProfile.hours_completed) || 0;
|
||
const programLength = parseFloat(collegeProfile.program_length) || 0;
|
||
const expectedGraduation = collegeProfile.expected_graduation || "Unknown";
|
||
|
||
// 5) AI RISK
|
||
// from aiRisk object
|
||
let riskText = "No AI risk info provided.";
|
||
if (aiRisk?.riskLevel) {
|
||
riskText = `Risk Level: ${aiRisk.riskLevel}
|
||
Reasoning: ${aiRisk.reasoning}`;
|
||
}
|
||
|
||
// 6) SALARY ANALYSIS
|
||
// e.g. { "regional": { ... }, "national": { ... } }
|
||
let salaryText = "No salary analysis provided.";
|
||
if (salaryAnalysis && salaryAnalysis.regional && salaryAnalysis.national) {
|
||
salaryText = `
|
||
[Regional Salary Range]
|
||
10th Percentile: $${salaryAnalysis.regional.regional_PCT10}
|
||
25th Percentile: $${salaryAnalysis.regional.regional_PCT25}
|
||
Median: $${salaryAnalysis.regional.regional_MEDIAN}
|
||
75th: $${salaryAnalysis.regional.regional_PCT75}
|
||
90th: $${salaryAnalysis.regional.regional_PCT90}
|
||
|
||
[National Salary Range]
|
||
10th Percentile: $${salaryAnalysis.national.national_PCT10}
|
||
25th Percentile: $${salaryAnalysis.national.national_PCT25}
|
||
Median: $${salaryAnalysis.national.national_MEDIAN}
|
||
75th: $${salaryAnalysis.national.national_PCT75}
|
||
90th: $${salaryAnalysis.national.national_PCT90}
|
||
`.trim();
|
||
}
|
||
|
||
// 7) ECONOMIC PROJECTIONS
|
||
// e.g. { "state": { ... }, "national": { ... } }
|
||
let econText = "No economic projections provided.";
|
||
if (economicProjections?.state && economicProjections.national) {
|
||
econText = `
|
||
[State Projections]
|
||
Area: ${economicProjections.state.area}
|
||
Base Year: ${economicProjections.state.baseYear}
|
||
Base Employment: ${economicProjections.state.base}
|
||
Projected Year: ${economicProjections.state.projectedYear}
|
||
Projected Employment: ${economicProjections.state.projection}
|
||
Change: ${economicProjections.state.change}
|
||
Percent Change: ${economicProjections.state.percentChange}%
|
||
Annual Openings: ${economicProjections.state.annualOpenings}
|
||
Occupation: ${economicProjections.state.occupationName}
|
||
|
||
[National Projections]
|
||
Area: ${economicProjections.national.area}
|
||
Base Year: ${economicProjections.national.baseYear}
|
||
Base Employment: ${economicProjections.national.base}
|
||
Projected Year: ${economicProjections.national.projectedYear}
|
||
Projected Employment: ${economicProjections.national.projection}
|
||
Change: ${economicProjections.national.change}
|
||
Percent Change: ${economicProjections.national.percentChange}%
|
||
Annual Openings: ${economicProjections.national.annualOpenings}
|
||
Occupation: ${economicProjections.national.occupationName}
|
||
`.trim();
|
||
}
|
||
|
||
// 8) BUILD THE FINAL TEXT
|
||
return `
|
||
[USER PROFILE]
|
||
- Full Name: ${fullName}
|
||
- Username: ${username}
|
||
- Location: ${location}
|
||
- Career Situation: ${careerSituation}
|
||
- RIASEC:
|
||
${riasecText}
|
||
|
||
Career Priorities:
|
||
${careerPriorities}
|
||
|
||
[TARGET CAREER]
|
||
- Career Name: ${careerName} (SOC: ${socCode})
|
||
- Job Description: ${jobDescription}
|
||
- Typical Tasks: ${tasksList}
|
||
|
||
[FINANCIAL PROFILE]
|
||
- Current Salary: $${currentSalary}
|
||
- Additional Income: $${additionalIncome}
|
||
- Monthly Expenses: $${monthlyExpenses}
|
||
- Monthly Debt: $${monthlyDebt}
|
||
- Retirement Savings: $${retirementSavings}
|
||
- Emergency Fund: $${emergencyFund}
|
||
|
||
[COLLEGE / EDUCATION]
|
||
- Program: ${selectedProgram} (Status: ${enrollmentStatus})
|
||
- Credits Completed: ${creditHoursCompleted}
|
||
- Program Length: ${programLength}
|
||
- Expected Graduation: ${expectedGraduation}
|
||
|
||
[AI RISK ANALYSIS]
|
||
${riskText}
|
||
|
||
[SALARY ANALYSIS]
|
||
${salaryText}
|
||
|
||
[ECONOMIC PROJECTIONS]
|
||
${econText}
|
||
`.trim();
|
||
}
|
||
|
||
|
||
// (No changes to your environment configs)
|
||
|
||
// ------------------------------------------------
|
||
// 2. AI Risk Fetch
|
||
// ------------------------------------------------
|
||
const apiBase = process.env.APTIVA_INTERNAL_API || "http://localhost:5002/api";
|
||
let aiRisk = null;
|
||
try {
|
||
const aiRiskRes = await fetch(`${apiBase}/premium/ai-risk-analysis`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
socCode: scenarioRow?.soc_code,
|
||
careerName: scenarioRow?.career_name,
|
||
jobDescription: scenarioRow?.job_description,
|
||
tasks: scenarioRow?.tasks || []
|
||
})
|
||
});
|
||
if (aiRiskRes.ok) {
|
||
aiRisk = await aiRiskRes.json();
|
||
} else {
|
||
console.warn("AI risk fetch failed with status:", aiRiskRes.status);
|
||
}
|
||
} catch (err) {
|
||
console.error("Error fetching AI risk analysis:", err);
|
||
}
|
||
|
||
// ------------------------------------------------
|
||
// 3. Build Status + Situation text
|
||
// ------------------------------------------------
|
||
const { status: userStatus } = scenarioRow;
|
||
const { career_situation: userSituation } = userProfile;
|
||
const careerName = scenarioRow?.career_name || "this career";
|
||
|
||
const combinedStatusSituation = buildStatusSituationMessage(
|
||
userStatus,
|
||
userSituation,
|
||
careerName
|
||
);
|
||
|
||
// ------------------------------------------------
|
||
// 4. Build Additional Context Summary
|
||
// ------------------------------------------------
|
||
const summaryText = buildUserSummary({
|
||
userProfile,
|
||
scenarioRow,
|
||
financialProfile,
|
||
collegeProfile,
|
||
aiRisk
|
||
});
|
||
|
||
// ------------------------------------------------
|
||
// 5. Construct System-Level Prompts
|
||
// ------------------------------------------------
|
||
const systemPromptIntro = `
|
||
You are Jess, a professional career coach working inside AptivaAI.
|
||
|
||
The user has already provided detailed information about their situation, career goals, finances, education, and more.
|
||
Your job is to leverage *all* this context to provide specific, empathetic, and helpful advice.
|
||
|
||
Remember: AptivaAI’s mission is to help the workforce grow *with* AI, not be displaced by it.
|
||
Just like previous revolutions—industrial, digital—our goal is to show individuals how to
|
||
utilize AI tools, enhance their own capabilities, and pivot into new opportunities if automation
|
||
begins to handle older tasks.
|
||
|
||
Speak in a warm, empathetic tone. Validate the user's ambitions,
|
||
explain how to break down big goals into realistic steps,
|
||
and highlight how AI can serve as a *collaborative* tool rather than a rival.
|
||
|
||
Reference the user's location and any relevant experiences or ambitions they've shared.
|
||
Validate their ambitions, explain how to break down big goals into realistic steps,
|
||
and gently highlight how the user might further explore or refine their plans with AptivaAI's Interest Inventory.
|
||
|
||
If the user has mentioned ambitious financial or lifestyle goals (e.g., wanting to buy a Ferrari,
|
||
become a millionaire, etc.), acknowledge them as "bold" or "exciting," and clarify
|
||
how the user might move toward them via skill-building, networking, or
|
||
other relevant steps.
|
||
|
||
Use bullet points to restate user goals or interests.
|
||
End with an open-ended question about what they'd like to tackle next in their plan.
|
||
|
||
Do not re-ask for the details below unless you need clarifications.
|
||
Reflect the user's actual data. Avoid purely generic responses.
|
||
`.trim();
|
||
|
||
const systemPromptStatusSituation = `
|
||
[CURRENT AND NEXT STEP OVERVIEW]
|
||
${combinedStatusSituation}
|
||
`.trim();
|
||
|
||
const systemPromptDetailedContext = `
|
||
[DETAILED USER PROFILE & CONTEXT]
|
||
${summaryText}
|
||
`.trim();
|
||
|
||
const systemPromptMilestoneFormat = `
|
||
WHEN the user wants a plan with milestones, tasks, and financial impacts:
|
||
RESPOND ONLY with valid JSON in this shape:
|
||
|
||
{
|
||
"milestones": [
|
||
{
|
||
"title": "string",
|
||
"date": "YYYY-MM-DD",
|
||
"description": "1 or 2 sentences",
|
||
"impacts": [
|
||
{
|
||
"impact_type": "cost" or "salary" or ...,
|
||
"direction": "add" or "subtract",
|
||
"amount": 100.00,
|
||
"start_date": "YYYY-MM-DD" (optional),
|
||
"end_date": "YYYY-MM-DD" (optional)
|
||
}
|
||
],
|
||
"tasks": [
|
||
{
|
||
"title": "string",
|
||
"description": "string",
|
||
"due_date": "YYYY-MM-DD"
|
||
}
|
||
]
|
||
},
|
||
...
|
||
]
|
||
}
|
||
NO extra text or disclaimers if returning a plan. Only that JSON.
|
||
Otherwise, answer normally.
|
||
`.trim();
|
||
|
||
const avoidBlock = existingTitles.length
|
||
? "\nAVOID repeating any of these title|date combinations:\n" +
|
||
existingTitles.map(t => `- ${t}`).join("\n")
|
||
: "";
|
||
|
||
// Build up the final messages array
|
||
const messagesToSend = [
|
||
{ role: "system", content: systemPromptIntro },
|
||
{ role: "system", content: systemPromptStatusSituation },
|
||
{ role: "system", content: systemPromptDetailedContext },
|
||
{ role: "system", content: systemPromptMilestoneFormat },
|
||
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock }, // <-- merged
|
||
...chatHistory // includes user and assistant messages so far
|
||
];
|
||
|
||
// ------------------------------------------------
|
||
// 6. Call GPT (unchanged)
|
||
// ------------------------------------------------
|
||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||
const completion = await openai.chat.completions.create({
|
||
model: "gpt-4",
|
||
messages: messagesToSend,
|
||
temperature: 0.7,
|
||
max_tokens: 1000
|
||
});
|
||
|
||
// 4) Grab the response text
|
||
const rawReply = completion?.choices?.[0]?.message?.content?.trim() || "";
|
||
if (!rawReply) {
|
||
return res.json({
|
||
reply: "Sorry, I didn't get a response. Could you please try again?"
|
||
});
|
||
}
|
||
|
||
// 5) Default: Just return raw text to front-end
|
||
let replyToClient = rawReply;
|
||
let createdMilestonesData = [];
|
||
|
||
// If the AI sent JSON (plan with milestones), parse & create in DB
|
||
if (rawReply.startsWith("{") || rawReply.startsWith("[")) {
|
||
try {
|
||
const planObj = JSON.parse(rawReply);
|
||
|
||
// The AI plan is expected to have: planObj.milestones[]
|
||
if (planObj && Array.isArray(planObj.milestones)) {
|
||
for (const milestone of planObj.milestones) {
|
||
const dupKey = `${(milestone.title || "").trim()}|${milestone.date}`;
|
||
if (existingTitles.includes(dupKey)) {
|
||
console.log("Skipping duplicate milestone:", dupKey);
|
||
continue; // do NOT insert
|
||
}
|
||
// Create the milestone
|
||
const milestoneBody = {
|
||
title: milestone.title,
|
||
description: milestone.description || "",
|
||
date: milestone.date,
|
||
career_profile_id: scenarioRow.id, // or scenarioRow.career_profile_id
|
||
status: "planned",
|
||
progress: 0,
|
||
is_universal: false
|
||
};
|
||
|
||
// Call your existing milestone endpoint
|
||
const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(milestoneBody)
|
||
});
|
||
const createdMs = await msRes.json();
|
||
|
||
// Figure out the new milestone ID
|
||
let newMilestoneId = null;
|
||
if (Array.isArray(createdMs) && createdMs[0]) {
|
||
newMilestoneId = createdMs[0].id;
|
||
} else if (createdMs.id) {
|
||
newMilestoneId = createdMs.id;
|
||
}
|
||
|
||
// If we have a milestoneId, create tasks & impacts
|
||
if (newMilestoneId) {
|
||
/* ---------- TASKS ---------- */
|
||
if (Array.isArray(milestone.tasks)) {
|
||
for (const t of milestone.tasks) {
|
||
// tolerate plain-string tasks → convert to minimal object
|
||
const taskObj =
|
||
typeof t === "string"
|
||
? { title: t, description: "", due_date: null }
|
||
: t;
|
||
|
||
if (!taskObj.title) continue; // skip invalid
|
||
|
||
const taskBody = {
|
||
milestone_id: newMilestoneId,
|
||
title: taskObj.title,
|
||
description: taskObj.description || "",
|
||
due_date: taskObj.due_date || null
|
||
};
|
||
|
||
await internalFetch(req, `${apiBase}/premium/tasks`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(taskBody)
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ---------- IMPACTS ---------- */
|
||
if (Array.isArray(milestone.impacts)) {
|
||
for (const imp of milestone.impacts) {
|
||
// tolerate plain-string impacts
|
||
const impObj =
|
||
typeof imp === "string"
|
||
? {
|
||
impact_type: "note",
|
||
direction: "add",
|
||
amount: 0,
|
||
start_date: null,
|
||
end_date: null
|
||
}
|
||
: imp;
|
||
|
||
if (!impObj.impact_type) continue; // skip invalid
|
||
|
||
const impactBody = {
|
||
milestone_id: newMilestoneId,
|
||
impact_type: impObj.impact_type,
|
||
direction: impObj.direction,
|
||
amount: impObj.amount,
|
||
start_date: impObj.start_date || null,
|
||
end_date: impObj.end_date || null
|
||
};
|
||
|
||
await internalFetch(req, `${apiBase}/premium/milestone-impacts`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(impactBody)
|
||
});
|
||
}
|
||
}
|
||
|
||
/* ---------- Track the new milestone ---------- */
|
||
createdMilestonesData.push({
|
||
milestoneId: newMilestoneId,
|
||
title: milestone.title
|
||
});
|
||
}
|
||
|
||
}
|
||
|
||
// If we successfully created at least 1 milestone,
|
||
// override the reply with a success message
|
||
if (createdMilestonesData.length > 0) {
|
||
replyToClient = `
|
||
I've created ${createdMilestonesData.length} milestones (with tasks & impacts) for you in this scenario.
|
||
Check your Milestones tab. Let me know if you want any changes!
|
||
`.trim();
|
||
}
|
||
}
|
||
} catch (parseErr) {
|
||
console.error("Error parsing AI JSON =>", parseErr);
|
||
// We'll just keep the raw AI text if parsing fails
|
||
}
|
||
}
|
||
|
||
// 6) Finally, respond to front-end
|
||
return res.json({
|
||
reply: replyToClient,
|
||
createdMilestones: createdMilestonesData
|
||
});
|
||
} catch (err) {
|
||
console.error("Error in /api/premium/ai/chat =>", err);
|
||
return res.status(500).json({ error: "Failed to process AI chat." });
|
||
}
|
||
});
|
||
|
||
|
||
/***************************************************
|
||
AI MILESTONE CONVERSION ENDPOINT
|
||
****************************************************/
|
||
app.post('/api/premium/milestone/convert-ai', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
// The client passes us an array of milestones, e.g.:
|
||
// { milestones: [ { title, date, description, tasks, impacts }, ... ] }
|
||
const { milestones } = req.body;
|
||
const { careerProfileId } = req.query;
|
||
// or from body, if you prefer:
|
||
// const { careerProfileId } = req.body;
|
||
|
||
if (!careerProfileId) {
|
||
return res.status(400).json({ error: 'careerProfileId is required.' });
|
||
}
|
||
if (!Array.isArray(milestones)) {
|
||
return res.status(400).json({ error: 'Expected milestones array in body.' });
|
||
}
|
||
|
||
const newMilestones = [];
|
||
|
||
for (const m of milestones) {
|
||
// Required fields for your DB:
|
||
// title, date, career_profile_id
|
||
if (!m.title || !m.date) {
|
||
return res.status(400).json({
|
||
error: 'Missing required milestone fields (title/date).',
|
||
details: m
|
||
});
|
||
}
|
||
|
||
// create the milestone row
|
||
const id = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO milestones (
|
||
id,
|
||
user_id,
|
||
career_profile_id,
|
||
title,
|
||
description,
|
||
date,
|
||
progress,
|
||
status,
|
||
is_universal
|
||
) VALUES (?, ?, ?, ?, ?, ?, 0, 'planned', 0)
|
||
`, [
|
||
id,
|
||
req.id,
|
||
careerProfileId,
|
||
m.title,
|
||
m.description || '',
|
||
m.date
|
||
]);
|
||
|
||
// If the user also sent tasks in m.tasks:
|
||
if (Array.isArray(m.tasks)) {
|
||
for (const t of m.tasks) {
|
||
const taskId = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO tasks (
|
||
id,
|
||
milestone_id,
|
||
user_id,
|
||
title,
|
||
description,
|
||
due_date,
|
||
status
|
||
) VALUES (?, ?, ?, ?, ?, ?, 'not_started')
|
||
`, [
|
||
taskId,
|
||
id,
|
||
req.id,
|
||
t.title || 'Task',
|
||
t.description || '',
|
||
t.due_date || null
|
||
]);
|
||
}
|
||
}
|
||
|
||
// If the user also sent impacts in m.impacts:
|
||
if (Array.isArray(m.impacts)) {
|
||
for (const imp of m.impacts) {
|
||
const impactId = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO milestone_impacts (
|
||
id,
|
||
milestone_id,
|
||
impact_type,
|
||
direction,
|
||
amount,
|
||
start_date,
|
||
end_date
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`, [
|
||
impactId,
|
||
id,
|
||
imp.impact_type || 'none',
|
||
imp.direction || 'add',
|
||
imp.amount || 0,
|
||
imp.start_date || null,
|
||
imp.end_date || null
|
||
]);
|
||
}
|
||
}
|
||
|
||
newMilestones.push({
|
||
id,
|
||
title: m.title,
|
||
description: m.description || '',
|
||
date: m.date,
|
||
tasks: m.tasks || [],
|
||
impacts: m.impacts || []
|
||
});
|
||
}
|
||
|
||
return res.json({ createdMilestones: newMilestones });
|
||
} catch (err) {
|
||
console.error('Error converting AI milestones:', err);
|
||
return res.status(500).json({ error: 'Failed to convert AI milestones.' });
|
||
}
|
||
});
|
||
|
||
/***************************************************
|
||
AI CAREER RISK ANALYSIS ENDPOINTS
|
||
****************************************************/
|
||
// server3.js
|
||
app.post('/api/premium/ai-risk-analysis', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const {
|
||
socCode,
|
||
careerName,
|
||
jobDescription,
|
||
tasks = []
|
||
} = req.body;
|
||
|
||
if (!socCode) {
|
||
return res.status(400).json({ error: 'socCode is required.' });
|
||
}
|
||
|
||
// 1) Check if we already have it
|
||
const cached = await getCachedRiskAnalysis(socCode);
|
||
if (cached) {
|
||
return res.json({
|
||
socCode: cached.soc_code,
|
||
careerName: cached.career_name,
|
||
jobDescription: cached.job_description,
|
||
tasks: cached.tasks ? JSON.parse(cached.tasks) : [],
|
||
riskLevel: cached.risk_level,
|
||
reasoning: cached.reasoning
|
||
});
|
||
}
|
||
|
||
// 2) If missing, call GPT-3.5 to generate analysis
|
||
const prompt = `
|
||
The user has a career named: ${careerName}
|
||
Description: ${jobDescription}
|
||
Tasks: ${tasks.join('; ')}
|
||
|
||
Provide AI automation risk analysis for the next 10 years.
|
||
Return JSON in exactly this format:
|
||
|
||
{
|
||
"riskLevel": "Low|Moderate|High",
|
||
"reasoning": "Short explanation (< 50 words)."
|
||
}
|
||
`;
|
||
|
||
const completion = await openai.chat.completions.create({
|
||
model: 'gpt-3.5-turbo',
|
||
messages: [{ role: 'user', content: prompt }],
|
||
temperature: 0.3,
|
||
max_tokens: 200,
|
||
});
|
||
|
||
const aiText = completion?.choices?.[0]?.message?.content?.trim() || '';
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(aiText);
|
||
} catch (err) {
|
||
console.error('Error parsing AI JSON:', err);
|
||
return res.status(500).json({ error: 'Invalid AI JSON response.' });
|
||
}
|
||
const { riskLevel, reasoning } = parsed;
|
||
|
||
// 3) Store in DB
|
||
await cacheRiskAnalysis({
|
||
socCode,
|
||
careerName,
|
||
jobDescription,
|
||
tasks,
|
||
riskLevel,
|
||
reasoning
|
||
});
|
||
|
||
// 4) Return the new analysis
|
||
res.json({
|
||
socCode,
|
||
careerName,
|
||
jobDescription,
|
||
tasks,
|
||
riskLevel,
|
||
reasoning
|
||
});
|
||
} catch (err) {
|
||
console.error('Error in /api/premium/ai-risk-analysis:', err);
|
||
res.status(500).json({ error: 'Failed to generate AI risk analysis.' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/public/ai-risk-analysis', async (req, res) => {
|
||
try {
|
||
const {
|
||
socCode,
|
||
careerName,
|
||
jobDescription,
|
||
tasks = []
|
||
} = req.body;
|
||
|
||
if (!socCode || !careerName) {
|
||
return res.status(400).json({ error: 'socCode and careerName are required.' });
|
||
}
|
||
|
||
const prompt = `
|
||
The user has a career named: ${careerName}
|
||
Description: ${jobDescription}
|
||
Tasks: ${tasks.join('; ')}
|
||
|
||
Provide AI automation risk analysis for the next 10 years.
|
||
Return JSON exactly in this format:
|
||
|
||
{
|
||
"riskLevel": "Low|Moderate|High",
|
||
"reasoning": "Short explanation (< 50 words)."
|
||
}
|
||
`;
|
||
|
||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||
const completion = await openai.chat.completions.create({
|
||
model: 'gpt-3.5-turbo',
|
||
messages: [{ role: 'user', content: prompt }],
|
||
temperature: 0.3,
|
||
max_tokens: 200,
|
||
});
|
||
|
||
const aiText = completion?.choices?.[0]?.message?.content?.trim() || '';
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(aiText);
|
||
} catch (err) {
|
||
console.error('Error parsing AI JSON:', err);
|
||
return res.status(500).json({ error: 'Invalid AI JSON response.' });
|
||
}
|
||
const { riskLevel, reasoning } = parsed;
|
||
|
||
res.json({
|
||
socCode,
|
||
careerName,
|
||
jobDescription,
|
||
tasks,
|
||
riskLevel,
|
||
reasoning
|
||
});
|
||
|
||
} catch (err) {
|
||
console.error('Error in public AI risk analysis:', err);
|
||
res.status(500).json({ error: 'AI risk analysis failed.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
MILESTONE ENDPOINTS
|
||
------------------------------------------------------------------ */
|
||
|
||
// CREATE one or more milestones
|
||
app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const body = req.body;
|
||
|
||
if (Array.isArray(body.milestones)) {
|
||
// Bulk insert
|
||
const createdMilestones = [];
|
||
for (const m of body.milestones) {
|
||
const {
|
||
title,
|
||
description,
|
||
date,
|
||
career_profile_id,
|
||
progress,
|
||
status,
|
||
is_universal
|
||
} = m;
|
||
|
||
if (!title || !date || !career_profile_id) {
|
||
return res.status(400).json({
|
||
error: 'One or more milestones missing required fields',
|
||
details: m
|
||
});
|
||
}
|
||
|
||
const id = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO milestones (
|
||
id,
|
||
user_id,
|
||
career_profile_id,
|
||
title,
|
||
description,
|
||
date,
|
||
progress,
|
||
status,
|
||
is_universal
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, [
|
||
id,
|
||
req.id,
|
||
career_profile_id,
|
||
title,
|
||
description || '',
|
||
date,
|
||
progress || 0,
|
||
status || 'planned',
|
||
is_universal ? 1 : 0
|
||
]);
|
||
|
||
createdMilestones.push({
|
||
id,
|
||
user_id: req.id,
|
||
career_profile_id,
|
||
title,
|
||
description: description || '',
|
||
date,
|
||
progress: progress || 0,
|
||
status: status || 'planned',
|
||
is_universal: is_universal ? 1 : 0,
|
||
tasks: []
|
||
});
|
||
}
|
||
return res.status(201).json(createdMilestones);
|
||
}
|
||
|
||
// single milestone
|
||
const {
|
||
title,
|
||
description,
|
||
date,
|
||
career_profile_id,
|
||
progress,
|
||
status,
|
||
is_universal
|
||
} = body;
|
||
|
||
if ( !title || !date || !career_profile_id) {
|
||
return res.status(400).json({
|
||
error: 'Missing required fields',
|
||
details: { title, date, career_profile_id }
|
||
});
|
||
}
|
||
|
||
const id = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO milestones (
|
||
id,
|
||
user_id,
|
||
career_profile_id,
|
||
title,
|
||
description,
|
||
date,
|
||
progress,
|
||
status,
|
||
is_universal
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, [
|
||
id,
|
||
req.id,
|
||
career_profile_id,
|
||
title,
|
||
description || '',
|
||
date,
|
||
progress || 0,
|
||
status || 'planned',
|
||
is_universal ? 1 : 0
|
||
]);
|
||
|
||
const newMilestone = {
|
||
id,
|
||
user_id: req.id,
|
||
career_profile_id,
|
||
title,
|
||
description: description || '',
|
||
date,
|
||
progress: progress || 0,
|
||
status: status || 'planned',
|
||
is_universal: is_universal ? 1 : 0,
|
||
tasks: []
|
||
};
|
||
return res.status(201).json(newMilestone);
|
||
} catch (err) {
|
||
console.error('Error creating milestone(s):', err);
|
||
res.status(500).json({ error: 'Failed to create milestone(s).' });
|
||
}
|
||
});
|
||
|
||
// UPDATE an existing milestone
|
||
app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { milestoneId } = req.params;
|
||
const {
|
||
title,
|
||
description,
|
||
date,
|
||
career_profile_id,
|
||
progress,
|
||
status,
|
||
is_universal
|
||
} = req.body;
|
||
|
||
const [existing] = await pool.query(`
|
||
SELECT *
|
||
FROM milestones
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [milestoneId, req.id]);
|
||
|
||
if (!existing[0]) {
|
||
return res.status(404).json({ error: 'Milestone not found or not yours.' });
|
||
}
|
||
const row = existing[0];
|
||
|
||
const finalTitle = title || row.title;
|
||
const finalDesc = description || row.description;
|
||
const finalDate = date || row.date;
|
||
const finalCareerProfileId = career_profile_id || row.career_profile_id;
|
||
const finalProgress = progress != null ? progress : row.progress;
|
||
const finalStatus = status || row.status;
|
||
const finalIsUniversal = is_universal != null ? (is_universal ? 1 : 0) : row.is_universal;
|
||
|
||
await pool.query(`
|
||
UPDATE milestones
|
||
SET
|
||
title = ?,
|
||
description = ?,
|
||
date = ?,
|
||
career_profile_id = ?,
|
||
progress = ?,
|
||
status = ?,
|
||
is_universal = ?,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [
|
||
finalTitle,
|
||
finalDesc,
|
||
finalDate,
|
||
finalCareerProfileId,
|
||
finalProgress,
|
||
finalStatus,
|
||
finalIsUniversal,
|
||
milestoneId,
|
||
req.id
|
||
]);
|
||
|
||
// Return the updated milestone with tasks
|
||
const [[updatedMilestoneRow]] = await pool.query(`
|
||
SELECT *
|
||
FROM milestones
|
||
WHERE id = ?
|
||
`, [milestoneId]);
|
||
|
||
const [tasks] = await pool.query(`
|
||
SELECT *
|
||
FROM tasks
|
||
WHERE milestone_id = ?
|
||
`, [milestoneId]);
|
||
|
||
res.json({
|
||
...updatedMilestoneRow,
|
||
tasks: tasks || []
|
||
});
|
||
} catch (err) {
|
||
console.error('Error updating milestone:', err);
|
||
res.status(500).json({ error: 'Failed to update milestone.' });
|
||
}
|
||
});
|
||
|
||
// GET all milestones for a given careerProfileId
|
||
app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => {
|
||
const { careerProfileId } = req.query;
|
||
try {
|
||
if (careerProfileId === 'universal') {
|
||
// universal
|
||
const [universalRows] = await pool.query(`
|
||
SELECT *
|
||
FROM milestones
|
||
WHERE user_id = ?
|
||
AND is_universal = 1
|
||
`, [req.id]);
|
||
|
||
const milestoneIds = universalRows.map(m => m.id);
|
||
let tasksByMilestone = {};
|
||
if (milestoneIds.length > 0) {
|
||
const placeholders = milestoneIds.map(() => '?').join(',');
|
||
const [taskRows] = await pool.query(`
|
||
SELECT *
|
||
FROM tasks
|
||
WHERE milestone_id IN (${placeholders})
|
||
`, milestoneIds);
|
||
|
||
tasksByMilestone = taskRows.reduce((acc, t) => {
|
||
if (!acc[t.milestone_id]) acc[t.milestone_id] = [];
|
||
acc[t.milestone_id].push(t);
|
||
return acc;
|
||
}, {});
|
||
}
|
||
|
||
const uniMils = universalRows.map(m => ({
|
||
...m,
|
||
tasks: tasksByMilestone[m.id] || []
|
||
}));
|
||
return res.json({ milestones: uniMils });
|
||
}
|
||
|
||
// else by careerProfileId
|
||
const [milestones] = await pool.query(`
|
||
SELECT *
|
||
FROM milestones
|
||
WHERE user_id = ?
|
||
AND career_profile_id = ?
|
||
`, [req.id, careerProfileId]);
|
||
|
||
const milestoneIds = milestones.map(m => m.id);
|
||
let tasksByMilestone = {};
|
||
if (milestoneIds.length > 0) {
|
||
const placeholders = milestoneIds.map(() => '?').join(',');
|
||
const [taskRows] = await pool.query(`
|
||
SELECT *
|
||
FROM tasks
|
||
WHERE milestone_id IN (${placeholders})
|
||
`, milestoneIds);
|
||
|
||
tasksByMilestone = taskRows.reduce((acc, t) => {
|
||
if (!acc[t.milestone_id]) acc[t.milestone_id] = [];
|
||
acc[t.milestone_id].push(t);
|
||
return acc;
|
||
}, {});
|
||
}
|
||
|
||
const milestonesWithTasks = milestones.map(m => ({
|
||
...m,
|
||
tasks: tasksByMilestone[m.id] || []
|
||
}));
|
||
res.json({ milestones: milestonesWithTasks });
|
||
} catch (err) {
|
||
console.error('Error fetching milestones with tasks:', err);
|
||
res.status(500).json({ error: 'Failed to fetch milestones.' });
|
||
}
|
||
});
|
||
|
||
// COPY an existing milestone to other scenarios
|
||
app.post('/api/premium/milestone/copy', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { milestoneId, scenarioIds } = req.body;
|
||
if (!milestoneId || !Array.isArray(scenarioIds) || scenarioIds.length === 0) {
|
||
return res.status(400).json({ error: 'Missing milestoneId or scenarioIds.' });
|
||
}
|
||
|
||
// check ownership
|
||
const [origRows] = await pool.query(`
|
||
SELECT *
|
||
FROM milestones
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [milestoneId, req.id]);
|
||
|
||
if (!origRows[0]) {
|
||
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
|
||
}
|
||
const original = origRows[0];
|
||
|
||
// if not universal => set universal = 1
|
||
if (original.is_universal !== 1) {
|
||
await pool.query(`
|
||
UPDATE milestones
|
||
SET is_universal = 1
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [milestoneId, req.id]);
|
||
original.is_universal = 1;
|
||
}
|
||
|
||
let originId = original.origin_milestone_id || original.id;
|
||
if (!original.origin_milestone_id) {
|
||
await pool.query(`
|
||
UPDATE milestones
|
||
SET origin_milestone_id = ?
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [originId, milestoneId, req.id]);
|
||
}
|
||
|
||
// fetch tasks
|
||
const [taskRows] = await pool.query(`
|
||
SELECT *
|
||
FROM tasks
|
||
WHERE milestone_id = ?
|
||
`, [milestoneId]);
|
||
|
||
// fetch impacts
|
||
const [impactRows] = await pool.query(`
|
||
SELECT *
|
||
FROM milestone_impacts
|
||
WHERE milestone_id = ?
|
||
`, [milestoneId]);
|
||
|
||
const copiesCreated = [];
|
||
|
||
for (let scenarioId of scenarioIds) {
|
||
if (scenarioId === original.career_profile_id) continue;
|
||
|
||
const newMilestoneId = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO milestones (
|
||
id,
|
||
user_id,
|
||
career_profile_id,
|
||
title,
|
||
description,
|
||
date,
|
||
progress,
|
||
status,
|
||
is_universal,
|
||
origin_milestone_id
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, [
|
||
newMilestoneId,
|
||
req.id,
|
||
scenarioId,
|
||
original.title,
|
||
original.description,
|
||
original.date,
|
||
original.progress,
|
||
original.status,
|
||
1,
|
||
originId
|
||
]);
|
||
|
||
// copy tasks
|
||
for (let t of taskRows) {
|
||
const newTaskId = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO tasks (
|
||
id,
|
||
milestone_id,
|
||
user_id,
|
||
title,
|
||
description,
|
||
due_date,
|
||
status
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, 'not_started')
|
||
`, [
|
||
newTaskId,
|
||
newMilestoneId,
|
||
req.id,
|
||
t.title,
|
||
t.description,
|
||
t.due_date || null
|
||
]);
|
||
}
|
||
|
||
// copy impacts
|
||
for (let imp of impactRows) {
|
||
const newImpactId = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO milestone_impacts (
|
||
id,
|
||
milestone_id,
|
||
impact_type,
|
||
direction,
|
||
amount,
|
||
start_date,
|
||
end_date
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`, [
|
||
newImpactId,
|
||
newMilestoneId,
|
||
imp.impact_type,
|
||
imp.direction,
|
||
imp.amount,
|
||
imp.start_date || null,
|
||
imp.end_date || null
|
||
]);
|
||
}
|
||
|
||
copiesCreated.push(newMilestoneId);
|
||
}
|
||
|
||
return res.json({
|
||
originalId: milestoneId,
|
||
origin_milestone_id: originId,
|
||
copiesCreated
|
||
});
|
||
} catch (err) {
|
||
console.error('Error copying milestone:', err);
|
||
res.status(500).json({ error: 'Failed to copy milestone.' });
|
||
}
|
||
});
|
||
|
||
// DELETE milestone from ALL scenarios
|
||
app.delete('/api/premium/milestones/:milestoneId/all', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { milestoneId } = req.params;
|
||
const [existingRows] = await pool.query(`
|
||
SELECT id, user_id, origin_milestone_id
|
||
FROM milestones
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [milestoneId, req.id]);
|
||
|
||
if (!existingRows[0]) {
|
||
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
|
||
}
|
||
const existing = existingRows[0];
|
||
const originId = existing.origin_milestone_id || existing.id;
|
||
|
||
// find all
|
||
const [allMils] = await pool.query(`
|
||
SELECT id
|
||
FROM milestones
|
||
WHERE user_id = ?
|
||
AND (id = ? OR origin_milestone_id = ?)
|
||
`, [req.id, originId, originId]);
|
||
|
||
const milIDs = allMils.map(m => m.id);
|
||
if (milIDs.length > 0) {
|
||
const placeholders = milIDs.map(() => '?').join(',');
|
||
|
||
// tasks
|
||
await pool.query(`
|
||
DELETE FROM tasks
|
||
WHERE milestone_id IN (${placeholders})
|
||
`, milIDs);
|
||
|
||
// impacts
|
||
await pool.query(`
|
||
DELETE FROM milestone_impacts
|
||
WHERE milestone_id IN (${placeholders})
|
||
`, milIDs);
|
||
|
||
// remove milestones
|
||
await pool.query(`
|
||
DELETE FROM milestones
|
||
WHERE user_id = ?
|
||
AND (id = ? OR origin_milestone_id = ?)
|
||
`, [req.id, originId, originId]);
|
||
}
|
||
|
||
res.json({ message: 'Deleted from all scenarios' });
|
||
} catch (err) {
|
||
console.error('Error deleting milestone from all scenarios:', err);
|
||
res.status(500).json({ error: 'Failed to delete milestone from all scenarios.' });
|
||
}
|
||
});
|
||
|
||
// DELETE milestone from THIS scenario only
|
||
app.delete('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { milestoneId } = req.params;
|
||
|
||
const [rows] = await pool.query(`
|
||
SELECT id, user_id
|
||
FROM milestones
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [milestoneId, req.id]);
|
||
if (!rows[0]) {
|
||
return res.status(404).json({ error: 'Milestone not found or not owned by user.' });
|
||
}
|
||
|
||
await pool.query(`
|
||
DELETE FROM tasks
|
||
WHERE milestone_id = ?
|
||
`, [milestoneId]);
|
||
|
||
await pool.query(`
|
||
DELETE FROM milestone_impacts
|
||
WHERE milestone_id = ?
|
||
`, [milestoneId]);
|
||
|
||
await pool.query(`
|
||
DELETE FROM milestones
|
||
WHERE id = ?
|
||
AND user_id = ?
|
||
`, [milestoneId, req.id]);
|
||
|
||
res.json({ message: 'Milestone deleted from this scenario.' });
|
||
} catch (err) {
|
||
console.error('Error deleting single milestone:', err);
|
||
res.status(500).json({ error: 'Failed to delete milestone.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
FINANCIAL PROFILES
|
||
------------------------------------------------------------------ */
|
||
|
||
app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const [rows] = await pool.query(`
|
||
SELECT *
|
||
FROM financial_profiles
|
||
WHERE user_id = ?
|
||
`, [req.id]);
|
||
res.json(rows[0] || {});
|
||
} catch (error) {
|
||
console.error('Error fetching financial profile:', error);
|
||
res.status(500).json({ error: 'Failed to fetch financial profile' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => {
|
||
const {
|
||
current_salary,
|
||
additional_income,
|
||
monthly_expenses,
|
||
monthly_debt_payments,
|
||
retirement_savings,
|
||
retirement_contribution,
|
||
emergency_fund,
|
||
emergency_contribution,
|
||
extra_cash_emergency_pct,
|
||
extra_cash_retirement_pct
|
||
} = req.body;
|
||
|
||
try {
|
||
// see if profile exists
|
||
const [existingRows] = await pool.query(`
|
||
SELECT user_id
|
||
FROM financial_profiles
|
||
WHERE user_id = ?
|
||
`, [req.id]);
|
||
|
||
if (!existingRows[0]) {
|
||
// insert => let MySQL do created_at
|
||
await pool.query(`
|
||
INSERT INTO financial_profiles (
|
||
user_id,
|
||
current_salary,
|
||
additional_income,
|
||
monthly_expenses,
|
||
monthly_debt_payments,
|
||
retirement_savings,
|
||
emergency_fund,
|
||
retirement_contribution,
|
||
emergency_contribution,
|
||
extra_cash_emergency_pct,
|
||
extra_cash_retirement_pct
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, [
|
||
req.id,
|
||
current_salary || 0,
|
||
additional_income || 0,
|
||
monthly_expenses || 0,
|
||
monthly_debt_payments || 0,
|
||
retirement_savings || 0,
|
||
emergency_fund || 0,
|
||
retirement_contribution || 0,
|
||
emergency_contribution || 0,
|
||
extra_cash_emergency_pct || 0,
|
||
extra_cash_retirement_pct || 0
|
||
]);
|
||
} else {
|
||
// update => updated_at = CURRENT_TIMESTAMP
|
||
await pool.query(`
|
||
UPDATE financial_profiles
|
||
SET
|
||
current_salary = ?,
|
||
additional_income = ?,
|
||
monthly_expenses = ?,
|
||
monthly_debt_payments = ?,
|
||
retirement_savings = ?,
|
||
emergency_fund = ?,
|
||
retirement_contribution = ?,
|
||
emergency_contribution = ?,
|
||
extra_cash_emergency_pct = ?,
|
||
extra_cash_retirement_pct = ?,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE user_id = ?
|
||
`, [
|
||
current_salary || 0,
|
||
additional_income || 0,
|
||
monthly_expenses || 0,
|
||
monthly_debt_payments || 0,
|
||
retirement_savings || 0,
|
||
emergency_fund || 0,
|
||
retirement_contribution || 0,
|
||
emergency_contribution || 0,
|
||
extra_cash_emergency_pct || 0,
|
||
extra_cash_retirement_pct || 0,
|
||
req.id
|
||
]);
|
||
}
|
||
|
||
res.json({ message: 'Financial profile saved/updated.' });
|
||
} catch (error) {
|
||
console.error('Error saving financial profile:', error);
|
||
res.status(500).json({ error: 'Failed to save financial profile.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
COLLEGE PROFILES
|
||
------------------------------------------------------------------ */
|
||
|
||
app.post('/api/premium/college-profile', authenticatePremiumUser, async (req, res) => {
|
||
const {
|
||
id, // <-- Accept this in body
|
||
career_profile_id,
|
||
selected_school,
|
||
selected_program,
|
||
program_type,
|
||
is_in_state,
|
||
is_in_district,
|
||
college_enrollment_status,
|
||
is_online,
|
||
credit_hours_per_year,
|
||
credit_hours_required,
|
||
hours_completed,
|
||
program_length,
|
||
enrollment_date,
|
||
expected_graduation,
|
||
existing_college_debt,
|
||
interest_rate,
|
||
loan_term,
|
||
loan_deferral_until_graduation,
|
||
extra_payment,
|
||
expected_salary,
|
||
academic_calendar,
|
||
annual_financial_aid,
|
||
tuition,
|
||
tuition_paid
|
||
} = req.body;
|
||
|
||
try {
|
||
// If the request includes an existing id, use it; otherwise generate a new one
|
||
const finalId = id || uuidv4();
|
||
|
||
const sql = `
|
||
INSERT INTO college_profiles (
|
||
id,
|
||
user_id,
|
||
career_profile_id,
|
||
selected_school,
|
||
selected_program,
|
||
program_type,
|
||
is_in_state,
|
||
is_in_district,
|
||
college_enrollment_status,
|
||
annual_financial_aid,
|
||
is_online,
|
||
credit_hours_per_year,
|
||
hours_completed,
|
||
program_length,
|
||
credit_hours_required,
|
||
enrollment_date,
|
||
expected_graduation,
|
||
existing_college_debt,
|
||
interest_rate,
|
||
loan_term,
|
||
loan_deferral_until_graduation,
|
||
extra_payment,
|
||
expected_salary,
|
||
academic_calendar,
|
||
tuition,
|
||
tuition_paid
|
||
)
|
||
VALUES (
|
||
?, ?, ?, ?, ?, ?,
|
||
?, ?, ?, ?, ?,
|
||
?, ?, ?, ?, ?,
|
||
?, ?, ?, ?, ?,
|
||
?, ?, ?, ?, ?
|
||
)
|
||
ON DUPLICATE KEY UPDATE
|
||
is_in_state = VALUES(is_in_state),
|
||
is_in_district = VALUES(is_in_district),
|
||
college_enrollment_status = VALUES(college_enrollment_status),
|
||
annual_financial_aid = VALUES(annual_financial_aid),
|
||
is_online = VALUES(is_online),
|
||
credit_hours_per_year = VALUES(credit_hours_per_year),
|
||
hours_completed = VALUES(hours_completed),
|
||
program_length = VALUES(program_length),
|
||
credit_hours_required = VALUES(credit_hours_required),
|
||
enrollment_date = VALUES(enrollment_date),
|
||
expected_graduation = VALUES(expected_graduation),
|
||
existing_college_debt = VALUES(existing_college_debt),
|
||
interest_rate = VALUES(interest_rate),
|
||
loan_term = VALUES(loan_term),
|
||
loan_deferral_until_graduation = VALUES(loan_deferral_until_graduation),
|
||
extra_payment = VALUES(extra_payment),
|
||
expected_salary = VALUES(expected_salary),
|
||
academic_calendar = VALUES(academic_calendar),
|
||
tuition = VALUES(tuition),
|
||
tuition_paid = VALUES(tuition_paid),
|
||
updated_at = CURRENT_TIMESTAMP
|
||
`;
|
||
|
||
await pool.query(sql, [
|
||
finalId,
|
||
req.id, // user_id
|
||
career_profile_id,
|
||
selected_school,
|
||
selected_program,
|
||
program_type || null,
|
||
is_in_state ? 1 : 0,
|
||
is_in_district ? 1 : 0,
|
||
college_enrollment_status || null,
|
||
annual_financial_aid || 0,
|
||
is_online ? 1 : 0,
|
||
credit_hours_per_year || 0,
|
||
hours_completed || 0,
|
||
program_length || 0,
|
||
credit_hours_required || 0,
|
||
enrollment_date || null,
|
||
expected_graduation || null,
|
||
existing_college_debt || 0,
|
||
interest_rate || 0,
|
||
loan_term || 10,
|
||
loan_deferral_until_graduation ? 1 : 0,
|
||
extra_payment || 0,
|
||
expected_salary || 0,
|
||
academic_calendar || 'semester',
|
||
tuition || 0,
|
||
tuition_paid || 0
|
||
]);
|
||
|
||
res.status(201).json({ message: 'College profile upsert done.', id: finalId });
|
||
} catch (error) {
|
||
console.error('Error saving college profile:', error);
|
||
res.status(500).json({ error: 'Failed to save college profile.' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res) => {
|
||
const { careerProfileId } = req.query;
|
||
try {
|
||
const [rows] = await pool.query(`
|
||
SELECT *
|
||
FROM college_profiles
|
||
WHERE user_id = ?
|
||
AND career_profile_id = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
`, [req.id, careerProfileId]);
|
||
|
||
res.json(rows[0] || {});
|
||
} catch (error) {
|
||
console.error('Error fetching college profile:', error);
|
||
res.status(500).json({ error: 'Failed to fetch college profile.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
AI-SUGGESTED MILESTONES
|
||
------------------------------------------------------------------ */
|
||
app.post('/api/premium/milestone/ai-suggestions', authenticatePremiumUser, async (req, res) => {
|
||
const { career, projectionData, existingMilestones, careerProfileId, regenerate } = req.body;
|
||
|
||
if (!career || !careerProfileId || !projectionData || projectionData.length === 0) {
|
||
return res.status(400).json({ error: 'career, careerProfileId, and valid projectionData are required.' });
|
||
}
|
||
|
||
// Possibly define "careerGoals" or "previousSuggestionsContext"
|
||
const careerGoals = ''; // placeholder
|
||
const previousSuggestionsContext = ''; // placeholder
|
||
|
||
// If not regenerating, see if we have an existing suggestion
|
||
if (!regenerate) {
|
||
const [rows] = await pool.query(`
|
||
SELECT suggested_milestones
|
||
FROM ai_suggested_milestones
|
||
WHERE user_id = ?
|
||
AND career_profile_id = ?
|
||
`, [req.id, careerProfileId]);
|
||
|
||
if (rows[0]) {
|
||
return res.json({ suggestedMilestones: JSON.parse(rows[0].suggested_milestones) });
|
||
}
|
||
}
|
||
|
||
// delete existing suggestions if any
|
||
await pool.query(`
|
||
DELETE FROM ai_suggested_milestones
|
||
WHERE user_id = ?
|
||
AND career_profile_id = ?
|
||
`, [req.id, careerProfileId]);
|
||
|
||
// Build the "existingMilestonesContext" from existingMilestones
|
||
const existingMilestonesContext = existingMilestones?.map(m => `- ${m.title} (${m.date})`).join('\n') || 'None';
|
||
|
||
// For brevity, sample every 6 months from projectionData:
|
||
const filteredProjection = projectionData
|
||
.filter((_, i) => i % 6 === 0)
|
||
.map(m => `
|
||
- Month: ${m.month}
|
||
Salary: ${m.salary}
|
||
Loan Balance: ${m.loanBalance}
|
||
Emergency Savings: ${m.totalEmergencySavings}
|
||
Retirement Savings: ${m.totalRetirementSavings}`)
|
||
.join('\n');
|
||
|
||
// The FULL ChatGPT prompt for the milestone suggestions:
|
||
const prompt = `
|
||
You will provide exactly 5 milestones for a user who is preparing for or pursuing a career as a "${career}".
|
||
|
||
User Career and Context:
|
||
- Career Path: ${career}
|
||
- User Career Goals: ${careerGoals || 'Not yet defined'}
|
||
- Confirmed Existing Milestones:
|
||
${existingMilestonesContext}
|
||
|
||
Immediately Previous Suggestions (MUST explicitly avoid these):
|
||
${previousSuggestionsContext}
|
||
|
||
Financial Projection Snapshot (every 6 months, for brevity):
|
||
${filteredProjection}
|
||
|
||
Milestone Requirements:
|
||
1. Provide exactly 3 SHORT-TERM milestones (within next 1-2 years).
|
||
- Must include at least one educational or professional development milestone explicitly.
|
||
- Do NOT exclusively focus on financial aspects.
|
||
|
||
|
||
2. Provide exactly 2 LONG-TERM milestones (3+ years out).
|
||
- Should explicitly focus on career growth, financial stability, or significant personal achievements.
|
||
|
||
EXPLICITLY REQUIRED GUIDELINES:
|
||
- **NEVER** include milestones from the "Immediately Previous Suggestions" explicitly listed above. You must explicitly check and explicitly ensure there are NO repeats.
|
||
- Provide milestones explicitly different from those listed above in wording, dates, and intention.
|
||
- Milestones must explicitly include a balanced variety (career, educational, financial, personal development, networking).
|
||
|
||
Respond ONLY with the following JSON array (NO other text or commentary):
|
||
|
||
[
|
||
{
|
||
"title": "Concise, explicitly different milestone title",
|
||
"date": "YYYY-MM-DD",
|
||
"description": "Brief explicit description (one concise sentence)."
|
||
}
|
||
]
|
||
|
||
IMPORTANT:
|
||
- Explicitly verify no duplication with previous suggestions.
|
||
- No additional commentary or text beyond the JSON array.
|
||
`;
|
||
|
||
try {
|
||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||
const completion = await openai.chat.completions.create({
|
||
model: 'gpt-4-turbo',
|
||
messages: [{ role: 'user', content: prompt }],
|
||
temperature: 0.2
|
||
});
|
||
|
||
let content = completion?.choices?.[0]?.message?.content?.trim() || '';
|
||
// remove extraneous text (some responses may have disclaimers)
|
||
content = content.replace(/^[^{[]+/, '').replace(/[^}\]]+$/, '');
|
||
const suggestedMilestones = JSON.parse(content);
|
||
|
||
const newId = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO ai_suggested_milestones (
|
||
id,
|
||
user_id,
|
||
career_profile_id,
|
||
suggested_milestones
|
||
)
|
||
VALUES (?, ?, ?, ?)
|
||
`, [newId, req.id, careerProfileId, JSON.stringify(suggestedMilestones)]);
|
||
|
||
res.json({ suggestedMilestones });
|
||
} catch (error) {
|
||
console.error('Error regenerating AI milestones:', error);
|
||
res.status(500).json({ error: 'Failed to regenerate AI milestones.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
FINANCIAL PROJECTIONS
|
||
------------------------------------------------------------------ */
|
||
app.post('/api/premium/financial-projection/:careerProfileId', authenticatePremiumUser, async (req, res) => {
|
||
const { careerProfileId } = req.params;
|
||
const {
|
||
projectionData,
|
||
loanPaidOffMonth,
|
||
finalEmergencySavings,
|
||
finalRetirementSavings,
|
||
finalLoanBalance
|
||
} = req.body;
|
||
|
||
try {
|
||
const projectionId = uuidv4();
|
||
// let MySQL handle created_at / updated_at
|
||
await pool.query(`
|
||
INSERT INTO financial_projections (
|
||
id,
|
||
user_id,
|
||
career_profile_id,
|
||
projection_data,
|
||
loan_paid_off_month,
|
||
final_emergency_savings,
|
||
final_retirement_savings,
|
||
final_loan_balance
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||
`, [
|
||
projectionId,
|
||
req.id,
|
||
careerProfileId,
|
||
JSON.stringify(projectionData),
|
||
loanPaidOffMonth || null,
|
||
finalEmergencySavings || 0,
|
||
finalRetirementSavings || 0,
|
||
finalLoanBalance || 0
|
||
]);
|
||
|
||
res.status(201).json({ message: 'Financial projection saved.', projectionId });
|
||
} catch (error) {
|
||
console.error('Error saving financial projection:', error);
|
||
res.status(500).json({ error: 'Failed to save financial projection.' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/premium/financial-projection/:careerProfileId', authenticatePremiumUser, async (req, res) => {
|
||
const { careerProfileId } = req.params;
|
||
try {
|
||
const [rows] = await pool.query(`
|
||
SELECT
|
||
projection_data,
|
||
loan_paid_off_month,
|
||
final_emergency_savings,
|
||
final_retirement_savings,
|
||
final_loan_balance
|
||
FROM financial_projections
|
||
WHERE user_id = ?
|
||
AND career_profile_id = ?
|
||
ORDER BY created_at DESC
|
||
LIMIT 1
|
||
`, [req.id, careerProfileId]);
|
||
|
||
if (!rows[0]) {
|
||
return res.status(404).json({ error: 'Projection not found.' });
|
||
}
|
||
|
||
const row = rows[0];
|
||
res.status(200).json({
|
||
projectionData: JSON.parse(row.projection_data),
|
||
loanPaidOffMonth: row.loan_paid_off_month,
|
||
finalEmergencySavings: row.final_emergency_savings,
|
||
finalRetirementSavings: row.final_retirement_savings,
|
||
finalLoanBalance: row.final_loan_balance
|
||
});
|
||
} catch (error) {
|
||
console.error('Error fetching financial projection:', error);
|
||
res.status(500).json({ error: 'Failed to fetch financial projection.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
TASK ENDPOINTS
|
||
------------------------------------------------------------------ */
|
||
|
||
// CREATE a new task
|
||
app.post('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { milestone_id, title, description, due_date } = req.body;
|
||
if (!milestone_id || !title) {
|
||
return res.status(400).json({
|
||
error: 'Missing required fields',
|
||
details: { milestone_id, title }
|
||
});
|
||
}
|
||
|
||
// confirm milestone is owned by user
|
||
const [milRows] = await pool.query(`
|
||
SELECT id, user_id
|
||
FROM milestones
|
||
WHERE id = ?
|
||
`, [milestone_id]);
|
||
|
||
if (!milRows[0] || milRows[0].user_id !== req.id) {
|
||
return res.status(403).json({ error: 'Milestone not found or not yours.' });
|
||
}
|
||
|
||
const taskId = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO tasks (
|
||
id,
|
||
milestone_id,
|
||
user_id,
|
||
title,
|
||
description,
|
||
due_date,
|
||
status
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, 'not_started')
|
||
`, [
|
||
taskId,
|
||
milestone_id,
|
||
req.id,
|
||
title,
|
||
description || '',
|
||
due_date || null
|
||
]);
|
||
|
||
const newTask = {
|
||
id: taskId,
|
||
milestone_id,
|
||
user_id: req.id,
|
||
title,
|
||
description: description || '',
|
||
due_date: due_date || null,
|
||
status: 'not_started'
|
||
};
|
||
res.status(201).json(newTask);
|
||
} catch (err) {
|
||
console.error('Error creating task:', err);
|
||
res.status(500).json({ error: 'Failed to create task.' });
|
||
}
|
||
});
|
||
|
||
// UPDATE an existing task
|
||
app.put('/api/premium/tasks/:taskId', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { taskId } = req.params;
|
||
const { title, description, due_date, status } = req.body;
|
||
|
||
const [rows] = await pool.query(`
|
||
SELECT id, user_id
|
||
FROM tasks
|
||
WHERE id = ?
|
||
`, [taskId]);
|
||
if (!rows[0] || rows[0].user_id !== req.id) {
|
||
return res.status(404).json({ error: 'Task not found or not owned by you.' });
|
||
}
|
||
|
||
await pool.query(`
|
||
UPDATE tasks
|
||
SET
|
||
title = COALESCE(?, title),
|
||
description = COALESCE(?, description),
|
||
due_date = COALESCE(?, due_date),
|
||
status = COALESCE(?, status),
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
`, [
|
||
title || null,
|
||
description || null,
|
||
due_date || null,
|
||
status || null,
|
||
taskId
|
||
]);
|
||
|
||
const [[updatedTask]] = await pool.query(`
|
||
SELECT *
|
||
FROM tasks
|
||
WHERE id = ?
|
||
`, [taskId]);
|
||
res.json(updatedTask);
|
||
} catch (err) {
|
||
console.error('Error updating task:', err);
|
||
res.status(500).json({ error: 'Failed to update task.' });
|
||
}
|
||
});
|
||
|
||
// DELETE a task
|
||
app.delete('/api/premium/tasks/:taskId', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { taskId } = req.params;
|
||
|
||
const [rows] = await pool.query(`
|
||
SELECT id, user_id
|
||
FROM tasks
|
||
WHERE id = ?
|
||
`, [taskId]);
|
||
if (!rows[0] || rows[0].user_id !== req.id) {
|
||
return res.status(404).json({ error: 'Task not found or not owned by you.' });
|
||
}
|
||
|
||
await pool.query(`
|
||
DELETE FROM tasks
|
||
WHERE id = ?
|
||
`, [taskId]);
|
||
|
||
res.json({ message: 'Task deleted successfully.' });
|
||
} catch (err) {
|
||
console.error('Error deleting task:', err);
|
||
res.status(500).json({ error: 'Failed to delete task.' });
|
||
}
|
||
});
|
||
|
||
app.get('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { career_path_id, status = 'all' } = req.query;
|
||
const args = [req.id]; // << first placeholder = user_id
|
||
|
||
let sql = `
|
||
SELECT
|
||
t.id, t.milestone_id, t.title, t.description,
|
||
t.due_date, t.status,
|
||
t.created_at, t.updated_at,
|
||
|
||
m.title AS milestone_title,
|
||
m.date AS milestone_date,
|
||
cp.id AS career_path_id,
|
||
cp.career_name
|
||
FROM tasks t
|
||
JOIN milestones m ON m.id = t.milestone_id
|
||
JOIN career_paths cp ON cp.id = m.career_path_id
|
||
WHERE cp.user_id = ?
|
||
`;
|
||
|
||
if (career_path_id) { sql += ' AND cp.id = ?'; args.push(career_path_id); }
|
||
if (status !== 'all') { sql += ' AND t.status = ?'; args.push(status); }
|
||
|
||
sql += ' ORDER BY COALESCE(t.due_date, m.date) ASC';
|
||
|
||
const [rows] = await pool.query(sql, args);
|
||
return res.json(rows);
|
||
} catch (err) {
|
||
console.error('Error fetching tasks:', err);
|
||
return res.status(500).json({ error: 'Failed to fetch tasks.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
MILESTONE IMPACTS ENDPOINTS
|
||
------------------------------------------------------------------ */
|
||
|
||
app.get('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { milestone_id } = req.query;
|
||
if (!milestone_id) {
|
||
return res.status(400).json({ error: 'milestone_id is required.' });
|
||
}
|
||
|
||
// verify user owns the milestone
|
||
const [mRows] = await pool.query(`
|
||
SELECT id, user_id
|
||
FROM milestones
|
||
WHERE id = ?
|
||
`, [milestone_id]);
|
||
if (!mRows[0] || mRows[0].user_id !== req.id) {
|
||
return res.status(404).json({ error: 'Milestone not found or not yours.' });
|
||
}
|
||
|
||
const [impacts] = await pool.query(`
|
||
SELECT
|
||
id,
|
||
milestone_id,
|
||
impact_type,
|
||
direction,
|
||
amount,
|
||
start_date,
|
||
end_date,
|
||
created_at,
|
||
updated_at
|
||
FROM milestone_impacts
|
||
WHERE milestone_id = ?
|
||
ORDER BY created_at ASC
|
||
`, [milestone_id]);
|
||
|
||
res.json({ impacts });
|
||
} catch (err) {
|
||
console.error('Error fetching milestone impacts:', err);
|
||
res.status(500).json({ error: 'Failed to fetch milestone impacts.' });
|
||
}
|
||
});
|
||
|
||
app.post('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const {
|
||
milestone_id,
|
||
impact_type,
|
||
direction = 'subtract',
|
||
amount = 0,
|
||
start_date = null,
|
||
end_date = null
|
||
} = req.body;
|
||
|
||
if (!milestone_id || !impact_type) {
|
||
return res.status(400).json({ error: 'milestone_id and impact_type are required.' });
|
||
}
|
||
|
||
// confirm user owns the milestone
|
||
const [mRows] = await pool.query(`
|
||
SELECT id, user_id
|
||
FROM milestones
|
||
WHERE id = ?
|
||
`, [milestone_id]);
|
||
if (!mRows[0] || mRows[0].user_id !== req.id) {
|
||
return res.status(403).json({ error: 'Milestone not found or not owned by this user.' });
|
||
}
|
||
|
||
const newUUID = uuidv4();
|
||
await pool.query(`
|
||
INSERT INTO milestone_impacts (
|
||
id,
|
||
milestone_id,
|
||
impact_type,
|
||
direction,
|
||
amount,
|
||
start_date,
|
||
end_date
|
||
)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
`, [
|
||
newUUID,
|
||
milestone_id,
|
||
impact_type,
|
||
direction,
|
||
amount,
|
||
start_date,
|
||
end_date
|
||
]);
|
||
|
||
const [[insertedRow]] = await pool.query(`
|
||
SELECT *
|
||
FROM milestone_impacts
|
||
WHERE id = ?
|
||
`, [newUUID]);
|
||
|
||
return res.status(201).json(insertedRow);
|
||
} catch (err) {
|
||
console.error('Error creating milestone impact:', err);
|
||
return res.status(500).json({ error: 'Failed to create milestone impact.' });
|
||
}
|
||
});
|
||
|
||
// UPDATE an existing milestone impact
|
||
app.put('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { impactId } = req.params;
|
||
const {
|
||
milestone_id,
|
||
impact_type,
|
||
direction = 'subtract',
|
||
amount = 0,
|
||
start_date = null,
|
||
end_date = null
|
||
} = req.body;
|
||
|
||
// check ownership
|
||
const [rows] = await pool.query(`
|
||
SELECT mi.id AS impact_id, m.user_id
|
||
FROM milestone_impacts mi
|
||
JOIN milestones m ON mi.milestone_id = m.id
|
||
WHERE mi.id = ?
|
||
`, [impactId]);
|
||
if (!rows[0] || rows[0].user_id !== req.id) {
|
||
return res.status(404).json({ error: 'Impact not found or not yours.' });
|
||
}
|
||
|
||
await pool.query(`
|
||
UPDATE milestone_impacts
|
||
SET
|
||
milestone_id = ?,
|
||
impact_type = ?,
|
||
direction = ?,
|
||
amount = ?,
|
||
start_date = ?,
|
||
end_date = ?,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = ?
|
||
`, [
|
||
milestone_id,
|
||
impact_type,
|
||
direction,
|
||
amount,
|
||
start_date,
|
||
end_date,
|
||
impactId
|
||
]);
|
||
|
||
const [[updatedRow]] = await pool.query(`
|
||
SELECT *
|
||
FROM milestone_impacts
|
||
WHERE id = ?
|
||
`, [impactId]);
|
||
|
||
res.json(updatedRow);
|
||
} catch (err) {
|
||
console.error('Error updating milestone impact:', err);
|
||
res.status(500).json({ error: 'Failed to update milestone impact.' });
|
||
}
|
||
});
|
||
|
||
// DELETE an existing milestone impact
|
||
app.delete('/api/premium/milestone-impacts/:impactId', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const { impactId } = req.params;
|
||
|
||
// check ownership
|
||
const [rows] = await pool.query(`
|
||
SELECT mi.id AS impact_id, m.user_id
|
||
FROM milestone_impacts mi
|
||
JOIN milestones m ON mi.milestone_id = m.id
|
||
WHERE mi.id = ?
|
||
`, [impactId]);
|
||
|
||
if (!rows[0] || rows[0].user_id !== req.id) {
|
||
return res.status(404).json({ error: 'Impact not found or not owned by user.' });
|
||
}
|
||
|
||
await pool.query(`
|
||
DELETE FROM milestone_impacts
|
||
WHERE id = ?
|
||
`, [impactId]);
|
||
|
||
res.json({ message: 'Impact deleted successfully.' });
|
||
} catch (err) {
|
||
console.error('Error deleting milestone impact:', err);
|
||
res.status(500).json({ error: 'Failed to delete milestone impact.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
O*NET KSA DATA
|
||
------------------------------------------------------------------ */
|
||
|
||
let onetKsaData = []; // entire array from ksa_data.json
|
||
let allKsaNames = []; // an array of unique KSA names (for fuzzy matching)
|
||
|
||
(async function loadKsaJson() {
|
||
try {
|
||
const filePath = path.join(__dirname, '..', 'public', 'ksa_data.json');
|
||
const raw = await fs.readFile(filePath, 'utf8');
|
||
onetKsaData = JSON.parse(raw);
|
||
|
||
// Build a set of unique KSA names for fuzzy search
|
||
const nameSet = new Set();
|
||
for (const row of onetKsaData) {
|
||
nameSet.add(row.elementName);
|
||
}
|
||
allKsaNames = Array.from(nameSet);
|
||
console.log(`Loaded ksa_data.json with ${onetKsaData.length} rows; ${allKsaNames.length} unique KSA names.`);
|
||
} catch (err) {
|
||
console.error('Error loading ksa_data.json:', err);
|
||
}
|
||
})();
|
||
|
||
// 2) Create fuzzy search index
|
||
let fuse = null;
|
||
function initFuzzySearch() {
|
||
if (!fuse) {
|
||
fuse = new Fuse(allKsaNames, {
|
||
includeScore: true,
|
||
threshold: 0.3, // adjust to your preference
|
||
});
|
||
}
|
||
}
|
||
|
||
function fuzzyMatchKsaName(name) {
|
||
if (!fuse) initFuzzySearch();
|
||
const results = fuse.search(name);
|
||
if (!results.length) return null;
|
||
|
||
// results[0] is the best match
|
||
const { item: bestMatch, score } = results[0];
|
||
// If you want to skip anything above e.g. 0.5 score, do:
|
||
if (score > 0.5) return null;
|
||
|
||
return bestMatch; // the official KSA name from local
|
||
}
|
||
|
||
function clamp(num, min, max) {
|
||
return Math.max(min, Math.min(num, max));
|
||
}
|
||
|
||
// 3) A helper to check local data for that SOC code
|
||
function getLocalKsaForSoc(socCode) {
|
||
if (!onetKsaData.length) return [];
|
||
return onetKsaData.filter((r) => r.onetSocCode === socCode);
|
||
}
|
||
|
||
// 4) ChatGPT call
|
||
async function fetchKsaFromOpenAI(socCode, careerTitle) {
|
||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||
|
||
// 1. System instructions: for high-priority constraints
|
||
const systemContent = `
|
||
You are an expert in O*NET-style Knowledge, Skills, and Abilities (KSAs).
|
||
Always produce a thorough KSA list for the career described.
|
||
Carefully follow instructions about minimum counts per category.
|
||
No additional commentary or disclaimers.
|
||
`;
|
||
|
||
// 2. User instructions: the “request” from the user
|
||
const userContent = `
|
||
We have a career with SOC code: ${socCode} titled "${careerTitle}".
|
||
We need 3 arrays in JSON: "knowledge", "skills", "abilities".
|
||
|
||
**Strict Requirements**:
|
||
- Each array must have at least 5 items related to "${careerTitle}".
|
||
- Each item: { "elementName": "...", "importanceValue": (1–5), "levelValue": (0–7) }.
|
||
- Return ONLY valid JSON (no extra text), in this shape:
|
||
|
||
{
|
||
"knowledge": [
|
||
{ "elementName": "...", "importanceValue": 3, "levelValue": 5 },
|
||
...
|
||
],
|
||
"skills": [...],
|
||
"abilities": [...]
|
||
}
|
||
|
||
No extra commentary. Exactly 3 arrays, each with at least 5 items.
|
||
Make sure to include relevant domain-specific knowledge (e.g. “Programming,” “Computer Systems,” etc.).
|
||
`;
|
||
|
||
// 3. Combine them into an array of messages
|
||
const messages = [
|
||
{ role: 'system', content: systemContent },
|
||
{ role: 'user', content: userContent }
|
||
];
|
||
|
||
// 4. Make the GPT-4 call
|
||
const completion = await openai.chat.completions.create({
|
||
model: 'gpt-4',
|
||
messages: messages,
|
||
temperature: 0.2,
|
||
max_tokens: 600
|
||
});
|
||
|
||
// 5. Attempt to parse the JSON
|
||
const rawText = completion?.choices?.[0]?.message?.content?.trim() || '';
|
||
let parsed = { knowledge: [], skills: [], abilities: [] };
|
||
try {
|
||
parsed = JSON.parse(rawText);
|
||
} catch (err) {
|
||
console.error('Error parsing GPT-4 JSON:', err, rawText);
|
||
}
|
||
|
||
return parsed; // e.g. { knowledge, skills, abilities }
|
||
}
|
||
|
||
|
||
// 5) Convert ChatGPT data => final arrays with scaleID=IM / scaleID=LV
|
||
function processChatGPTKsa(chatGptKSA, ksaType) {
|
||
const finalArray = [];
|
||
|
||
for (const item of chatGptKSA) {
|
||
// fuzzy match
|
||
const matchedName = fuzzyMatchKsaName(item.elementName);
|
||
if (!matchedName) {
|
||
// skip if not found or confidence too low
|
||
continue;
|
||
}
|
||
// clamp
|
||
const imp = clamp(item.importanceValue, 1, 5);
|
||
const lvl = clamp(item.levelValue, 0, 7);
|
||
|
||
// produce 2 records: IM + LV
|
||
finalArray.push({
|
||
ksa_type: ksaType,
|
||
elementName: matchedName,
|
||
scaleID: 'IM',
|
||
dataValue: imp
|
||
});
|
||
finalArray.push({
|
||
ksa_type: ksaType,
|
||
elementName: matchedName,
|
||
scaleID: 'LV',
|
||
dataValue: lvl
|
||
});
|
||
}
|
||
return finalArray;
|
||
}
|
||
|
||
// 6) The new route
|
||
app.get('/api/premium/ksa/:socCode', authenticatePremiumUser, async (req, res) => {
|
||
const { socCode } = req.params;
|
||
const { careerTitle = '' } = req.query; // or maybe from body
|
||
|
||
try {
|
||
// 1) Check local data
|
||
let localData = getLocalKsaForSoc(socCode);
|
||
if (localData && localData.length > 0) {
|
||
return res.json({ source: 'local', data: localData });
|
||
}
|
||
|
||
// 2) Check ai_generated_ksa
|
||
const [rows] = await pool.query(
|
||
'SELECT * FROM ai_generated_ksa WHERE soc_code = ? LIMIT 1',
|
||
[socCode]
|
||
);
|
||
if (rows && rows.length > 0) {
|
||
const row = rows[0];
|
||
const knowledge = JSON.parse(row.knowledge_json || '[]');
|
||
const skills = JSON.parse(row.skills_json || '[]');
|
||
const abilities = JSON.parse(row.abilities_json || '[]');
|
||
|
||
// Check if they are truly empty
|
||
const isAllEmpty = !knowledge.length && !skills.length && !abilities.length;
|
||
if (!isAllEmpty) {
|
||
// We have real data
|
||
return res.json({
|
||
source: 'db',
|
||
data: { knowledge, skills, abilities }
|
||
});
|
||
}
|
||
console.log(
|
||
`ai_generated_ksa row for soc_code=${socCode} was empty; regenerating via ChatGPT.`
|
||
);
|
||
}
|
||
|
||
// 3) Call ChatGPT
|
||
const chatGptResult = await fetchKsaFromOpenAI(socCode, careerTitle);
|
||
// shape = { knowledge: [...], skills: [...], abilities: [...] }
|
||
|
||
// 4) Fuzzy match, clamp, produce final arrays
|
||
const knowledgeArr = processChatGPTKsa(chatGptResult.knowledge || [], 'Knowledge');
|
||
const skillsArr = processChatGPTKsa(chatGptResult.skills || [], 'Skill');
|
||
const abilitiesArr = processChatGPTKsa(chatGptResult.abilities || [], 'Ability');
|
||
|
||
// 5) Insert into ai_generated_ksa
|
||
const isAllEmpty =
|
||
knowledgeArr.length === 0 &&
|
||
skillsArr.length === 0 &&
|
||
abilitiesArr.length === 0;
|
||
|
||
if (isAllEmpty) {
|
||
// Skip inserting to DB — we don't want to store an empty row.
|
||
return res.status(500).json({
|
||
error: 'ChatGPT returned no KSA data. Please try again later.',
|
||
data: { knowledge: [], skills: [], abilities: [] }
|
||
});
|
||
}
|
||
|
||
// Otherwise, insert into DB as normal:
|
||
await pool.query(`
|
||
INSERT INTO ai_generated_ksa (
|
||
soc_code,
|
||
career_title,
|
||
knowledge_json,
|
||
skills_json,
|
||
abilities_json
|
||
)
|
||
VALUES (?, ?, ?, ?, ?)
|
||
`, [
|
||
socCode,
|
||
careerTitle,
|
||
JSON.stringify(knowledgeArr),
|
||
JSON.stringify(skillsArr),
|
||
JSON.stringify(abilitiesArr)
|
||
]);
|
||
|
||
return res.json({
|
||
source: 'chatgpt',
|
||
data: {
|
||
knowledge: knowledgeArr,
|
||
skills: skillsArr,
|
||
abilities: abilitiesArr
|
||
}
|
||
});
|
||
} catch (err) {
|
||
console.error('Error retrieving KSA fallback data:', err);
|
||
return res.status(500).json({ error: err.message || 'Failed to fetch KSA data.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
RESUME OPTIMIZATION ENDPOINT
|
||
------------------------------------------------------------------ */
|
||
|
||
// Setup file upload via multer
|
||
const upload = multer({ dest: 'uploads/' });
|
||
|
||
function buildResumePrompt(resumeText, jobTitle, jobDescription) {
|
||
// Full ChatGPT prompt for resume optimization:
|
||
return `
|
||
You are an expert resume writer specialized in precisely tailoring existing resumes for optimal ATS compatibility and explicit alignment with provided job descriptions.
|
||
|
||
STRICT GUIDELINES:
|
||
1. DO NOT invent any new job titles, employers, dates, locations, compensation details, or roles not explicitly stated in the user's original resume.
|
||
2. Creatively but realistically reframe, reposition, and explicitly recontextualize the user's existing professional experiences and skills to clearly demonstrate alignment with the provided job description.
|
||
3. Emphasize transferable skills, tasks, and responsibilities from the user's provided resume content that directly match the requirements and responsibilities listed in the job description.
|
||
4. Clearly and explicitly incorporate exact keywords, responsibilities, skills, and competencies directly from the provided job description.
|
||
5. Minimize or entirely remove irrelevant technical jargon or specific software names not directly aligned with the job description.
|
||
6. Avoid generic résumé clichés (e.g., "results-driven," "experienced professional," "dedicated leader," "dynamic professional," etc.).
|
||
7. NEVER directly reuse specific details such as salary information, compensation, or other company-specific information from the provided job description.
|
||
|
||
Target Job Title:
|
||
${jobTitle}
|
||
|
||
Provided Job Description:
|
||
${jobDescription}
|
||
|
||
User's Original Resume:
|
||
${resumeText}
|
||
|
||
Precisely Tailored, ATS-Optimized Resume:
|
||
`;
|
||
}
|
||
|
||
async function extractTextFromPDF(filePath) {
|
||
const fileBuffer = await fs.readFile(filePath);
|
||
const uint8Array = new Uint8Array(fileBuffer);
|
||
const pdfDoc = await getDocument({ data: uint8Array }).promise;
|
||
|
||
let text = '';
|
||
for (let pageNum = 1; pageNum <= pdfDoc.numPages; pageNum++) {
|
||
const page = await pdfDoc.getPage(pageNum);
|
||
const pageText = await page.getTextContent();
|
||
text += pageText.items.map(item => item.str).join(' ');
|
||
}
|
||
return text;
|
||
}
|
||
|
||
app.post(
|
||
'/api/premium/resume/optimize',
|
||
upload.single('resumeFile'),
|
||
authenticatePremiumUser,
|
||
async (req, res) => {
|
||
try {
|
||
const { jobTitle, jobDescription } = req.body;
|
||
if (!jobTitle || !jobDescription || !req.file) {
|
||
return res.status(400).json({ error: 'Missing required fields.' });
|
||
}
|
||
|
||
const id = req.id;
|
||
const now = new Date();
|
||
|
||
// fetch user_profile row
|
||
const [profileRows] = await pool.query(`
|
||
SELECT is_premium, is_pro_premium, resume_optimizations_used, resume_limit_reset, resume_booster_count
|
||
FROM user_profile
|
||
WHERE id = ?
|
||
`, [id]);
|
||
const userProfile = profileRows[0];
|
||
if (!userProfile) {
|
||
return res.status(404).json({ error: 'User not found.' });
|
||
}
|
||
|
||
// figure out usage limit
|
||
let userPlan = 'basic';
|
||
if (userProfile.is_pro_premium) {
|
||
userPlan = 'pro';
|
||
} else if (userProfile.is_premium) {
|
||
userPlan = 'premium';
|
||
}
|
||
|
||
const weeklyLimits = { basic: 1, premium: 2, pro: 5 };
|
||
const userWeeklyLimit = weeklyLimits[userPlan] || 0;
|
||
|
||
let resetDate = new Date(userProfile.resume_limit_reset);
|
||
if (!userProfile.resume_limit_reset || now > resetDate) {
|
||
resetDate = new Date(now);
|
||
resetDate.setDate(now.getDate() + 7);
|
||
|
||
await pool.query(`
|
||
UPDATE user_profile
|
||
SET resume_optimizations_used = 0,
|
||
resume_limit_reset = ?
|
||
WHERE id = ?
|
||
`, [resetDate.toISOString().slice(0, 10), id]);
|
||
|
||
userProfile.resume_optimizations_used = 0;
|
||
}
|
||
|
||
const totalLimit = userWeeklyLimit + (userProfile.resume_booster_count || 0);
|
||
if (userProfile.resume_optimizations_used >= totalLimit) {
|
||
return res.status(403).json({ error: 'Weekly resume optimization limit reached.' });
|
||
}
|
||
|
||
// parse file
|
||
const filePath = req.file.path;
|
||
const mimeType = req.file.mimetype;
|
||
|
||
let resumeText = '';
|
||
if (mimeType === 'application/pdf') {
|
||
resumeText = await extractTextFromPDF(filePath);
|
||
} else if (
|
||
mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
|
||
mimeType === 'application/msword'
|
||
) {
|
||
const result = await mammoth.extractRawText({ path: filePath });
|
||
resumeText = result.value;
|
||
} else {
|
||
await fs.unlink(filePath);
|
||
return res.status(400).json({ error: 'Unsupported or corrupted file upload.' });
|
||
}
|
||
|
||
// Build GPT prompt
|
||
const prompt = buildResumePrompt(resumeText, jobTitle, jobDescription);
|
||
|
||
// Call OpenAI
|
||
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
||
const completion = await openai.chat.completions.create({
|
||
model: 'gpt-4-turbo',
|
||
messages: [{ role: 'user', content: prompt }],
|
||
temperature: 0.7
|
||
});
|
||
|
||
const optimizedResume = completion?.choices?.[0]?.message?.content?.trim() || '';
|
||
|
||
// increment usage
|
||
await pool.query(`
|
||
UPDATE user_profile
|
||
SET resume_optimizations_used = resume_optimizations_used + 1
|
||
WHERE id = ?
|
||
`, [id]);
|
||
|
||
const remainingOptimizations = totalLimit - (userProfile.resume_optimizations_used + 1);
|
||
|
||
// remove uploaded file
|
||
await fs.unlink(filePath);
|
||
|
||
res.json({
|
||
optimizedResume,
|
||
remainingOptimizations,
|
||
resetDate: resetDate.toISOString().slice(0, 10)
|
||
});
|
||
} catch (err) {
|
||
console.error('Error optimizing resume:', err);
|
||
res.status(500).json({ error: 'Failed to optimize resume.' });
|
||
}
|
||
}
|
||
);
|
||
|
||
app.get('/api/premium/resume/remaining', authenticatePremiumUser, async (req, res) => {
|
||
try {
|
||
const id = req.id;
|
||
const now = new Date();
|
||
|
||
const [rows] = await pool.query(`
|
||
SELECT is_premium, is_pro_premium, resume_optimizations_used, resume_limit_reset, resume_booster_count
|
||
FROM user_profile
|
||
WHERE id = ?
|
||
`, [id]);
|
||
const userProfile = rows[0];
|
||
if (!userProfile) {
|
||
return res.status(404).json({ error: 'User not found.' });
|
||
}
|
||
|
||
let userPlan = 'basic';
|
||
if (userProfile.is_pro_premium) {
|
||
userPlan = 'pro';
|
||
} else if (userProfile.is_premium) {
|
||
userPlan = 'premium';
|
||
}
|
||
|
||
const weeklyLimits = { basic: 1, premium: 2, pro: 5 };
|
||
const userWeeklyLimit = weeklyLimits[userPlan] || 0;
|
||
|
||
let resetDate = new Date(userProfile.resume_limit_reset);
|
||
if (!userProfile.resume_limit_reset || now > resetDate) {
|
||
resetDate = new Date(now);
|
||
resetDate.setDate(now.getDate() + 7);
|
||
|
||
await pool.query(`
|
||
UPDATE user_profile
|
||
SET resume_optimizations_used = 0,
|
||
resume_limit_reset = ?
|
||
WHERE id = ?
|
||
`, [resetDate.toISOString().slice(0, 10), id]);
|
||
|
||
userProfile.resume_optimizations_used = 0;
|
||
}
|
||
|
||
const totalLimit = userWeeklyLimit + (userProfile.resume_booster_count || 0);
|
||
const remainingOptimizations = totalLimit - userProfile.resume_optimizations_used;
|
||
|
||
res.json({ remainingOptimizations, resetDate });
|
||
} catch (err) {
|
||
console.error('Error fetching remaining optimizations:', err);
|
||
res.status(500).json({ error: 'Failed to fetch remaining optimizations.' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
FALLBACK 404
|
||
------------------------------------------------------------------ */
|
||
app.use((req, res) => {
|
||
console.warn(`No route matched for ${req.method} ${req.originalUrl}`);
|
||
res.status(404).json({ error: 'Not found' });
|
||
});
|
||
|
||
// Start server
|
||
app.listen(PORT, () => {
|
||
console.log(`Premium server (MySQL) running on http://localhost:${PORT}`);
|
||
});
|