dev1/backend/server3.js

1637 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// 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 scenarios 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}`);
});