// server3.js import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import dotenv from 'dotenv'; import { open } from 'sqlite'; import sqlite3 from 'sqlite3'; import jwt from 'jsonwebtoken'; import { v4 as uuidv4 } from 'uuid'; import path from 'path'; import { fileURLToPath } from 'url'; // If you still need the projection logic somewhere else import { simulateFinancialProjection } from '../src/utils/FinancialProjectionService.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); dotenv.config({ path: path.resolve(__dirname, '..', '.env') }); const app = express(); const PORT = process.env.PREMIUM_PORT || 5002; let db; const initDB = async () => { try { db = await open({ filename: '/home/jcoakley/aptiva-dev1-app/user_profile.db', driver: sqlite3.Database }); console.log('Connected to user_profile.db for Premium Services.'); } catch (error) { console.error('Error connecting to premium database:', error); } }; initDB(); app.use(helmet()); app.use(express.json()); const allowedOrigins = ['https://dev1.aptivaai.com']; app.use(cors({ origin: allowedOrigins, credentials: true })); 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 { userId } = jwt.verify(token, SECRET_KEY); req.userId = userId; next(); } catch (error) { return res.status(403).json({ error: 'Invalid or expired token' }); } }; /* ------------------------------------------------------------------ CAREER PROFILE ENDPOINTS (Renamed from planned-path to career-profile) ------------------------------------------------------------------ */ // GET the latest selected career profile app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (req, res) => { try { const row = await db.get(` SELECT * FROM career_paths WHERE user_id = ? ORDER BY start_date DESC LIMIT 1 `, [req.userId]); res.json(row || {}); } 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 rows = await db.all(` SELECT * FROM career_paths WHERE user_id = ? ORDER BY start_date ASC `, [req.userId]); res.json({ careerPaths: 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/:careerPathId', authenticatePremiumUser, async (req, res) => { const { careerPathId } = req.params; try { const row = await db.get(` SELECT * FROM career_paths WHERE id = ? AND user_id = ? `, [careerPathId, req.userId]); if (!row) { return res.status(404).json({ error: 'Career path (scenario) not found or not yours.' }); } res.json(row); } 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 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, 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 newCareerPathId = uuidv4(); const now = new Date().toISOString(); // Upsert via ON CONFLICT(user_id, career_name) await db.run(` INSERT INTO career_paths ( id, user_id, scenario_title, career_name, status, start_date, projected_end_date, college_enrollment_status, currently_working, 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, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT(user_id, career_name) DO UPDATE SET status = excluded.status, start_date = excluded.start_date, projected_end_date = excluded.projected_end_date, college_enrollment_status = excluded.college_enrollment_status, currently_working = excluded.currently_working, planned_monthly_expenses = excluded.planned_monthly_expenses, planned_monthly_debt_payments = excluded.planned_monthly_debt_payments, planned_monthly_retirement_contribution = excluded.planned_monthly_retirement_contribution, planned_monthly_emergency_contribution = excluded.planned_monthly_emergency_contribution, planned_surplus_emergency_pct = excluded.planned_surplus_emergency_pct, planned_surplus_retirement_pct = excluded.planned_surplus_retirement_pct, planned_additional_income = excluded.planned_additional_income, updated_at = ? `, [ // 18 items for the INSERT columns newCareerPathId, // id req.userId, // user_id scenario_title || null, // scenario_title career_name, // career_name status || 'planned', // status start_date || now, // start_date projected_end_date || null, // projected_end_date college_enrollment_status || null, // college_enrollment_status currently_working || null, // currently_working planned_monthly_expenses ?? null, // planned_monthly_expenses planned_monthly_debt_payments ?? null, // planned_monthly_debt_payments planned_monthly_retirement_contribution ?? null, planned_monthly_emergency_contribution ?? null, planned_surplus_emergency_pct ?? null, planned_surplus_retirement_pct ?? null, planned_additional_income ?? null, now, // created_at now, // updated_at // Then 1 more param for "updated_at = ?" in the conflict update now ]); // Optionally fetch the row's ID or entire row after upsert const result = await db.get(` SELECT id FROM career_paths WHERE user_id = ? AND career_name = ? `, [req.userId, career_name]); res.status(200).json({ message: 'Career profile upserted.', career_path_id: result?.id }); } catch (error) { console.error('Error upserting career profile:', error); res.status(500).json({ error: 'Failed to upsert career profile.' }); } }); // Delete a career path (scenario) by ID app.delete('/api/premium/career-profile/:careerPathId', authenticatePremiumUser, async (req, res) => { const { careerPathId } = req.params; try { // 1) Confirm that this career_path belongs to the user const existing = await db.get( ` SELECT id FROM career_paths WHERE id = ? AND user_id = ? `, [careerPathId, req.userId] ); if (!existing) { return res.status(404).json({ error: 'Career path not found or not yours.' }); } // 2) Optionally delete the college_profile for this scenario // (If you always keep 1-to-1 relationship: careerPathId => college_profile) await db.run( ` DELETE FROM college_profiles WHERE user_id = ? AND career_path_id = ? `, [req.userId, careerPathId] ); // 3) Optionally delete scenario’s milestones // (and any associated tasks, impacts, etc.) // If you store tasks in tasks table, and impacts in milestone_impacts table: // First find scenario milestones const scenarioMilestones = await db.all( ` SELECT id FROM milestones WHERE user_id = ? AND career_path_id = ? `, [req.userId, careerPathId] ); const milestoneIds = scenarioMilestones.map((m) => m.id); if (milestoneIds.length > 0) { // Delete tasks for these milestones const placeholders = milestoneIds.map(() => '?').join(','); await db.run( ` DELETE FROM tasks WHERE milestone_id IN (${placeholders}) `, milestoneIds ); // Delete impacts for these milestones await db.run( ` DELETE FROM milestone_impacts WHERE milestone_id IN (${placeholders}) `, milestoneIds ); // Finally delete the milestones themselves await db.run( ` DELETE FROM milestones WHERE id IN (${placeholders}) `, milestoneIds ); } // 4) Finally delete the career_path row await db.run( ` DELETE FROM career_paths WHERE user_id = ? AND id = ? `, [req.userId, careerPathId] ); res.json({ message: 'Career path and related data successfully deleted.' }); } catch (error) { console.error('Error deleting career path:', error); res.status(500).json({ error: 'Failed to delete career path.' }); } }); /* ------------------------------------------------------------------ Milestone ENDPOINTS ------------------------------------------------------------------ */ // CREATE one or more milestones app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => { try { const body = req.body; // CASE 1: If client sent { milestones: [ ... ] }, do a bulk insert if (Array.isArray(body.milestones)) { const createdMilestones = []; for (const m of body.milestones) { const { milestone_type, title, description, date, career_path_id, progress, status, new_salary, is_universal } = m; // Validate some required fields if (!milestone_type || !title || !date || !career_path_id) { return res.status(400).json({ error: 'One or more milestones missing required fields', details: m }); } const id = uuidv4(); const now = new Date().toISOString(); await db.run(` INSERT INTO milestones ( id, user_id, career_path_id, milestone_type, title, description, date, progress, status, new_salary, is_universal, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, req.userId, career_path_id, milestone_type, title, description || '', date, progress || 0, status || 'planned', new_salary || null, is_universal ? 1 : 0, // store 1 or 0 now, now ]); createdMilestones.push({ id, user_id: req.userId, career_path_id, milestone_type, title, description: description || '', date, progress: progress || 0, status: status || 'planned', new_salary: new_salary || null, is_universal: is_universal ? 1 : 0, tasks: [] }); } // Return array of created milestones return res.status(201).json(createdMilestones); } // CASE 2: Single milestone creation const { milestone_type, title, description, date, career_path_id, progress, status, new_salary, is_universal } = body; if (!milestone_type || !title || !date || !career_path_id) { return res.status(400).json({ error: 'Missing required fields', details: { milestone_type, title, date, career_path_id } }); } const id = uuidv4(); const now = new Date().toISOString(); await db.run(` INSERT INTO milestones ( id, user_id, career_path_id, milestone_type, title, description, date, progress, status, new_salary, is_universal, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, req.userId, career_path_id, milestone_type, title, description || '', date, progress || 0, status || 'planned', new_salary || null, is_universal ? 1 : 0, now, now ]); // Return the newly created single milestone object const newMilestone = { id, user_id: req.userId, career_path_id, milestone_type, title, description: description || '', date, progress: progress || 0, status: status || 'planned', new_salary: new_salary || null, is_universal: is_universal ? 1 : 0, tasks: [] }; 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 { milestone_type, title, description, date, career_path_id, progress, status, new_salary, is_universal } = req.body; // Check if milestone exists and belongs to user const existing = await db.get(` SELECT * FROM milestones WHERE id = ? AND user_id = ? `, [milestoneId, req.userId]); if (!existing) { return res.status(404).json({ error: 'Milestone not found or not yours.' }); } const now = new Date().toISOString(); // Merge fields with existing if not provided const finalMilestoneType = milestone_type || existing.milestone_type; const finalTitle = title || existing.title; const finalDesc = description || existing.description; const finalDate = date || existing.date; const finalCareerPath = career_path_id || existing.career_path_id; const finalProgress = progress != null ? progress : existing.progress; const finalStatus = status || existing.status; const finalSalary = new_salary != null ? new_salary : existing.new_salary; const finalIsUniversal = is_universal != null ? (is_universal ? 1 : 0) : existing.is_universal; // Update row await db.run(` UPDATE milestones SET milestone_type = ?, title = ?, description = ?, date = ?, career_path_id = ?, progress = ?, status = ?, new_salary = ?, is_universal = ?, updated_at = ? WHERE id = ? AND user_id = ? `, [ finalMilestoneType, finalTitle, finalDesc, finalDate, finalCareerPath, finalProgress, finalStatus, finalSalary, finalIsUniversal, now, milestoneId, req.userId ]); // Return the updated record with tasks const updatedMilestoneRow = await db.get(` SELECT * FROM milestones WHERE id = ? `, [milestoneId]); // Fetch tasks for this milestone const tasks = await db.all(` SELECT * FROM tasks WHERE milestone_id = ? `, [milestoneId]); const updatedMilestone = { ...updatedMilestoneRow, tasks: tasks || [] }; res.json(updatedMilestone); } catch (err) { console.error('Error updating milestone:', err); res.status(500).json({ error: 'Failed to update milestone.' }); } }); // GET all milestones for a given careerPathId app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => { const { careerPathId } = req.query; try { // if user wants universal=1 only, e.g. careerPathId=universal if (careerPathId === 'universal') { // For example, fetch all is_universal=1 for the user: const universalRows = await db.all(` SELECT * FROM milestones WHERE user_id = ? AND is_universal = 1 `, [req.userId]); // attach tasks if needed const milestoneIds = universalRows.map(m => m.id); let tasksByMilestone = {}; if (milestoneIds.length > 0) { const tasks = await db.all(` SELECT * FROM tasks WHERE milestone_id IN (${milestoneIds.map(() => '?').join(',')}) `, milestoneIds); tasksByMilestone = tasks.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 fetch by careerPathId const milestones = await db.all(` SELECT * FROM milestones WHERE user_id = ? AND career_path_id = ? `, [req.userId, careerPathId]); const milestoneIds = milestones.map(m => m.id); let tasksByMilestone = {}; if (milestoneIds.length > 0) { const tasks = await db.all(` SELECT * FROM tasks WHERE milestone_id IN (${milestoneIds.map(() => '?').join(',')}) `, milestoneIds); tasksByMilestone = tasks.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.' }); } // 1) Fetch the original const original = await db.get(` SELECT * FROM milestones WHERE id = ? AND user_id = ? `, [milestoneId, req.userId]); if (!original) { return res.status(404).json({ error: 'Milestone not found or not owned by user.' }); } // 2) Force is_universal=1 on the original if (original.is_universal !== 1) { await db.run(` UPDATE milestones SET is_universal = 1 WHERE id = ? AND user_id = ? `, [ milestoneId, req.userId ]); // Also refresh "original" object if you want original.is_universal = 1; } // 3) If no origin_milestone_id, set it let originId = original.origin_milestone_id || original.id; if (!original.origin_milestone_id) { await db.run(` UPDATE milestones SET origin_milestone_id = ? WHERE id = ? AND user_id = ? `, [ originId, milestoneId, req.userId ]); } // 4) fetch tasks & impacts const tasks = await db.all(` SELECT * FROM tasks WHERE milestone_id = ? `, [milestoneId]); const impacts = await db.all(` SELECT * FROM milestone_impacts WHERE milestone_id = ? `, [milestoneId]); const now = new Date().toISOString(); const copiesCreated = []; for (let scenarioId of scenarioIds) { if (scenarioId === original.career_path_id) { continue; } const newMilestoneId = uuidv4(); // Always set isUniversal=1 on copies const isUniversal = 1; await db.run(` INSERT INTO milestones ( id, user_id, career_path_id, milestone_type, title, description, date, progress, status, new_salary, is_universal, origin_milestone_id, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ newMilestoneId, req.userId, scenarioId, original.milestone_type, original.title, original.description, original.date, original.progress, original.status, original.new_salary, isUniversal, originId, now, now ]); // copy tasks for (let t of tasks) { const newTaskId = uuidv4(); await db.run(` INSERT INTO tasks ( id, milestone_id, user_id, title, description, due_date, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, 'not_started', ?, ?) `, [ newTaskId, newMilestoneId, req.userId, t.title, t.description, t.due_date || null, now, now ]); } // copy impacts for (let imp of impacts) { const newImpactId = uuidv4(); await db.run(` INSERT INTO milestone_impacts ( id, milestone_id, impact_type, direction, amount, start_date, end_date, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ newImpactId, newMilestoneId, imp.impact_type, imp.direction, imp.amount, imp.start_date || null, imp.end_date || null, now, now ]); } 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) => { const { milestoneId } = req.params; try { // 1) Fetch the milestone const existing = await db.get(` SELECT id, user_id, origin_milestone_id FROM milestones WHERE id = ? AND user_id = ? `, [milestoneId, req.userId]); if (!existing) { return res.status(404).json({ error: 'Milestone not found or not owned by user.' }); } const originId = existing.origin_milestone_id || existing.id; // 2) Delete all copies referencing that origin await db.run(` DELETE FROM milestones WHERE user_id = ? AND origin_milestone_id = ? `, [req.userId, originId]); // Also delete the original if it doesn't store itself in origin_milestone_id await db.run(` DELETE FROM milestones WHERE user_id = ? AND id = ? AND origin_milestone_id IS NULL `, [req.userId, 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) => { const { milestoneId } = req.params; try { // 1) check user ownership const existing = await db.get(` SELECT id, user_id FROM milestones WHERE id = ? AND user_id = ? `, [milestoneId, req.userId]); if (!existing) { return res.status(404).json({ error: 'Milestone not found or not owned by user.' }); } // 2) Delete the single row await db.run(` DELETE FROM milestones WHERE id = ? AND user_id = ? `, [milestoneId, req.userId]); // optionally also remove tasks + impacts if you want // e.g.: // await db.run('DELETE FROM tasks WHERE milestone_id = ?', [milestoneId]); // await db.run('DELETE FROM milestone_impacts WHERE milestone_id = ?', [milestoneId]); 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 (Renamed emergency_contribution) ------------------------------------------------------------------ */ app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => { try { const row = await db.get(` SELECT * FROM financial_profiles WHERE user_id = ? `, [req.userId]); res.json(row || {}); } 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 { // Check if row exists const existing = await db.get(` SELECT user_id FROM financial_profiles WHERE user_id = ? `, [req.userId]); if (!existing) { // Insert new row await db.run(` 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, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, [ req.userId, 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, // store new field extra_cash_emergency_pct || 0, extra_cash_retirement_pct || 0 ]); } else { // Update existing await db.run(` 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, // updated field extra_cash_emergency_pct || 0, extra_cash_retirement_pct || 0, req.userId ]); } 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 { career_path_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, 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 { const user_id = req.userId; // For upsert, we either generate a new ID or (optionally) do a lookup for the old row's ID if you want to preserve it // For simplicity, let's generate a new ID each time. We'll handle the conflict resolution below. const newId = uuidv4(); // Now do an INSERT ... ON CONFLICT(...fields...). In SQLite, we reference 'excluded' for the new values. await db.run(` INSERT INTO college_profiles ( id, user_id, career_path_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, expected_graduation, existing_college_debt, interest_rate, loan_term, loan_deferral_until_graduation, extra_payment, expected_salary, academic_calendar, tuition, tuition_paid, created_at, updated_at ) VALUES ( :id, :user_id, :career_path_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, :expected_graduation, :existing_college_debt, :interest_rate, :loan_term, :loan_deferral_until_graduation, :extra_payment, :expected_salary, :academic_calendar, :tuition, :tuition_paid, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP ) -- The magic: ON CONFLICT(user_id, career_path_id, selected_school, selected_program, program_type) DO UPDATE SET is_in_state = excluded.is_in_state, is_in_district = excluded.is_in_district, college_enrollment_status = excluded.college_enrollment_status, annual_financial_aid = excluded.annual_financial_aid, is_online = excluded.is_online, credit_hours_per_year = excluded.credit_hours_per_year, hours_completed = excluded.hours_completed, program_length = excluded.program_length, credit_hours_required = excluded.credit_hours_required, expected_graduation = excluded.expected_graduation, existing_college_debt = excluded.existing_college_debt, interest_rate = excluded.interest_rate, loan_term = excluded.loan_term, loan_deferral_until_graduation = excluded.loan_deferral_until_graduation, extra_payment = excluded.extra_payment, expected_salary = excluded.expected_salary, academic_calendar = excluded.academic_calendar, tuition = excluded.tuition, tuition_paid = excluded.tuition_paid, updated_at = CURRENT_TIMESTAMP ; `, { ':id': newId, ':user_id': user_id, ':career_path_id': career_path_id, ':selected_school': selected_school, ':selected_program': selected_program, ':program_type': program_type || null, ':is_in_state': is_in_state ? 1 : 0, ':is_in_district': is_in_district ? 1 : 0, ':college_enrollment_status': college_enrollment_status || null, ':annual_financial_aid': annual_financial_aid || 0, ':is_online': is_online ? 1 : 0, ':credit_hours_per_year': credit_hours_per_year || 0, ':hours_completed': hours_completed || 0, ':program_length': program_length || 0, ':credit_hours_required': credit_hours_required || 0, ':expected_graduation': expected_graduation || null, ':existing_college_debt': existing_college_debt || 0, ':interest_rate': interest_rate || 0, ':loan_term': loan_term || 10, ':loan_deferral_until_graduation': loan_deferral_until_graduation ? 1 : 0, ':extra_payment': extra_payment || 0, ':expected_salary': expected_salary || 0, ':academic_calendar': academic_calendar || 'semester', ':tuition': tuition || 0, ':tuition_paid': tuition_paid || 0 }); // If it was a conflict, the existing row is updated. // If not, a new row is inserted with ID = newId. res.status(201).json({ message: 'College profile upsert done.', // You might do an extra SELECT here to find which ID the final row uses if you need it }); } 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 { careerPathId } = req.query; // find row const row = await db.get(` SELECT * FROM college_profiles WHERE user_id = ? AND career_path_id = ? ORDER BY created_at DESC LIMIT 1 `, [req.userId, careerPathId]); res.json(row || {}); }); /* ------------------------------------------------------------------ FINANCIAL PROJECTIONS ------------------------------------------------------------------ */ app.post('/api/premium/financial-projection/:careerPathId', authenticatePremiumUser, async (req, res) => { const { careerPathId } = req.params; const { projectionData, loanPaidOffMonth, finalEmergencySavings, finalRetirementSavings, finalLoanBalance } = req.body; try { const projectionId = uuidv4(); await db.run(` INSERT INTO financial_projections ( id, user_id, career_path_id, projection_data, loan_paid_off_month, final_emergency_savings, final_retirement_savings, final_loan_balance, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) `, [ projectionId, req.userId, careerPathId, 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/:careerPathId', authenticatePremiumUser, async (req, res) => { const { careerPathId } = req.params; try { const row = await db.get(` SELECT projection_data, loan_paid_off_month, final_emergency_savings, final_retirement_savings, final_loan_balance FROM financial_projections WHERE user_id = ? AND career_path_id = ? ORDER BY created_at DESC LIMIT 1 `, [req.userId, careerPathId]); if (!row) { return res.status(404).json({ error: 'Projection not found.' }); } const parsedProjectionData = JSON.parse(row.projection_data); res.status(200).json({ projectionData: parsedProjectionData, 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.' }); } }); // POST create a new task app.post('/api/premium/tasks', authenticatePremiumUser, async (req, res) => { try { const { milestone_id, title, description, due_date } = req.body; // Ensure required fields if (!milestone_id || !title) { return res.status(400).json({ error: 'Missing required fields', details: { milestone_id, title } }); } // Confirm milestone is owned by this user const milestone = await db.get(` SELECT user_id FROM milestones WHERE id = ? `, [milestone_id]); if (!milestone || milestone.user_id !== req.userId) { return res.status(403).json({ error: 'Milestone not found or not yours.' }); } const taskId = uuidv4(); const now = new Date().toISOString(); // Insert the new task await db.run(` INSERT INTO tasks ( id, milestone_id, user_id, title, description, due_date, status, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, 'not_started', ?, ?) `, [ taskId, milestone_id, req.userId, title, description || '', due_date || null, now, now ]); // Return the newly created task as JSON const newTask = { id: taskId, milestone_id, user_id: req.userId, 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.' }); } }); // GET tasks for a milestone app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => { const { careerPathId } = req.query; try { // 1. Fetch the milestones for this user + path const milestones = await db.all(` SELECT * FROM milestones WHERE user_id = ? AND career_path_id = ? `, [req.userId, careerPathId]); // 2. For each milestone, fetch tasks (or do a single join—see note below) // We'll do it in Node code for clarity: const milestoneIds = milestones.map(m => m.id); let tasksByMilestone = {}; if (milestoneIds.length > 0) { const tasks = await db.all(` SELECT * FROM tasks WHERE milestone_id IN (${milestoneIds.map(() => '?').join(',')}) `, milestoneIds); // Group tasks by milestone_id tasksByMilestone = tasks.reduce((acc, t) => { if (!acc[t.milestone_id]) acc[t.milestone_id] = []; acc[t.milestone_id].push(t); return acc; }, {}); } // 3. Attach tasks to each milestone object 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.' }); } }); /************************************************************************ * MILESTONE IMPACTS ENDPOINTS ************************************************************************/ app.get('/api/premium/milestone-impacts', authenticatePremiumUser, async (req, res) => { try { // Example: GET /api/premium/milestone-impacts?milestone_id=12345 const { milestone_id } = req.query; if (!milestone_id) { return res.status(400).json({ error: 'milestone_id is required.' }); } // Verify the milestone belongs to this user const milestoneRow = await db.get(` SELECT user_id FROM milestones WHERE id = ? `, [milestone_id]); if (!milestoneRow || milestoneRow.user_id !== req.userId) { return res.status(404).json({ error: 'Milestone not found or not owned by this user.' }); } // Fetch all impacts for that milestone const impacts = await db.all(` 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, created_at, updated_at } = req.body; // Basic checks if (!milestone_id || !impact_type) { return res.status(400).json({ error: 'milestone_id and impact_type are required.' }); } // Confirm user owns the milestone const milestoneRow = await db.get(` SELECT user_id FROM milestones WHERE id = ? `, [milestone_id]); if (!milestoneRow || milestoneRow.user_id !== req.userId) { return res.status(403).json({ error: 'Milestone not found or not owned by this user.' }); } // Generate UUID for this new Impact const newUUID = uuidv4(); const now = new Date().toISOString(); const finalCreated = created_at || now; const finalUpdated = updated_at || now; // Insert row WITH that UUID into the "id" column await db.run(` INSERT INTO milestone_impacts ( id, milestone_id, impact_type, direction, amount, start_date, end_date, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ newUUID, milestone_id, impact_type, direction, amount, start_date, end_date, finalCreated, finalUpdated ]); // Fetch & return the inserted row const insertedRow = await db.get(` SELECT id, milestone_id, impact_type, direction, amount, start_date, end_date, created_at, updated_at 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 (PUT) ************************************************************************/ 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; // 1) Check this impact belongs to user const existing = await db.get(` SELECT mi.id, m.user_id FROM milestone_impacts mi JOIN milestones m ON mi.milestone_id = m.id WHERE mi.id = ? `, [impactId]); if (!existing || existing.user_id !== req.userId) { return res.status(404).json({ error: 'Impact not found or not owned by user.' }); } const now = new Date().toISOString(); // 2) Update await db.run(` UPDATE milestone_impacts SET milestone_id = ?, impact_type = ?, direction = ?, amount = ?, start_date = ?, end_date = ?, updated_at = ? WHERE id = ? `, [ milestone_id, impact_type, direction, amount, start_date, end_date, now, impactId ]); // 3) Return updated const updatedRow = await db.get(` SELECT id, milestone_id, impact_type, direction, amount, start_date, end_date, created_at, updated_at 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; // 1) check ownership const existing = await db.get(` SELECT mi.id, m.user_id FROM milestone_impacts mi JOIN milestones m ON mi.milestone_id = m.id WHERE mi.id = ? `, [impactId]); if (!existing || existing.user_id !== req.userId) { return res.status(404).json({ error: 'Impact not found or not owned by user.' }); } // 2) Delete await db.run(` 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.' }); } }); app.use((req, res) => { console.warn(`No route matched for ${req.method} ${req.originalUrl}`); res.status(404).json({ error: 'Not found' }); }); app.listen(PORT, () => { console.log(`Premium server running on http://localhost:${PORT}`); });