Fixed some things, broke some others.

This commit is contained in:
Josh 2025-06-19 11:44:43 +00:00
parent 47819493cc
commit 1868c4348a
8 changed files with 543 additions and 380 deletions

View File

@ -1,29 +1,15 @@
// backend/config/mysqlPool.js // backend/config/mysqlPool.js
import mysql from 'mysql2/promise'; import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url); const pool = mysql.createPool({
const __dirname = path.dirname(__filename);
// load .env.<env>
dotenv.config({ path: path.resolve(__dirname, '..', `.env.${process.env.NODE_ENV || 'development'}`) });
/** decide: socket vs TCP */
let poolConfig;
if (process.env.DB_SOCKET) {
poolConfig = { socketPath: process.env.DB_SOCKET };
} else {
poolConfig = {
host : process.env.DB_HOST || '127.0.0.1', host : process.env.DB_HOST || '127.0.0.1',
port : process.env.DB_PORT || 3306, port : process.env.DB_PORT || 3306,
user : process.env.DB_USER || 'root', user : process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '', password : process.env.DB_PASSWORD || '',
}; database : process.env.DB_NAME || 'user_profile_db',
} waitForConnections : true,
poolConfig.database = process.env.DB_NAME || 'user_profile_db'; connectionLimit : 10,
poolConfig.waitForConnections = true; ...(process.env.DB_SOCKET ? { socketPath: process.env.DB_SOCKET } : {})
poolConfig.connectionLimit = 10; });
export default mysql.createPool(poolConfig); export default pool;

View File

@ -1,35 +1,31 @@
// // ─── server3.js ────────────────────────────────────────────────────────────
// server3.js - MySQL Version import path from 'path';
// import { fileURLToPath } from 'url';
import dotenv from 'dotenv';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const env = (process.env.NODE_ENV || 'development').trim();
const envPath = path.resolve(__dirname, '..', `.env.${env}`);
dotenv.config({ path: envPath }); // ✅ envs are now ready
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import helmet from 'helmet'; import helmet from 'helmet';
import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
import multer from 'multer'; import multer from 'multer';
import fetch from "node-fetch"; import fetch from 'node-fetch';
import mammoth from 'mammoth'; import mammoth from 'mammoth';
import { fileURLToPath } from 'url';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist'; import pkg from 'pdfjs-dist';
import db from './config/mysqlPool.js'; // Adjust path as necessary import db from './config/mysqlPool.js';
import './jobs/reminderCron.js'; import './jobs/reminderCron.js';
import OpenAI from 'openai'; import OpenAI from 'openai';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
import { createReminder } from './utils/smsService.js'; import { createReminder } from './utils/smsService.js';
const pool = db;
// 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 apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api";
@ -49,6 +45,7 @@ function internalFetch(req, url, opts = {}) {
} }
// 2) Basic middlewares // 2) Basic middlewares
app.use(helmet()); app.use(helmet());
app.use(express.json({ limit: '5mb' })); app.use(express.json({ limit: '5mb' }));
@ -73,6 +70,8 @@ const authenticatePremiumUser = (req, res, next) => {
} }
}; };
const pool = db;
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
CAREER PROFILE ENDPOINTS CAREER PROFILE ENDPOINTS
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
@ -513,17 +512,26 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
} = req.body; } = req.body;
let existingTitles = []; let existingTitles = [];
let miniGrid = "-none-"; // slim grid
try { try {
const [rows] = await pool.query( const [rows] = await pool.query(
`SELECT title, DATE_FORMAT(date,'%Y-%m-%d') AS d
`SELECT id, DATE_FORMAT(date,'%Y-%m-%d') AS d, title
FROM milestones FROM milestones
WHERE user_id = ? AND career_profile_id = ?`, WHERE user_id = ? AND career_profile_id = ?`,
[req.id, scenarioRow.id] [req.id, scenarioRow.id]
); );
existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`); existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`);
} catch (e) {
console.error("Could not fetch existing milestones =>", e); if (rows.length) {
miniGrid = rows.map(r => `${r.id}|${r.title.trim()}|${r.d}`).join("\n");
} }
} catch (e) {
console.error("Could not fetch existing milestones ⇒", e);
}
// ------------------------------------------------ // ------------------------------------------------
// 1. Helper Functions // 1. Helper Functions
// ------------------------------------------------ // ------------------------------------------------
@ -824,34 +832,70 @@ ${econText}
// 5. Construct System-Level Prompts // 5. Construct System-Level Prompts
// ------------------------------------------------ // ------------------------------------------------
const systemPromptIntro = ` const systemPromptIntro = `
You are Jess, a professional career coach working inside AptivaAI. +You are **Jess**, a professional career coach inside AptivaAI.
+Your mandate: turn the users real data into clear, empathetic, *actionable* guidance.
+
+
+What Jess can do directly in Aptiva
+
+ **Create** new milestones (with tasks & financial impacts)
+ **Update** any field on an existing milestone
+ **Delete** milestones that are no longer relevant
+ **Add / edit / remove** tasks inside a milestone
+ Run salary benchmarks, AI-risk checks, and financial projections
+
+
+Mission & Tone
+
+Our mission is to help people grow *with* AI rather than be displaced by it.
+Speak in a warm, encouraging tone, but prioritize *specific next steps* over generic motivation.
+Validate ambitions, break big goals into realistic milestones, and show how AI can be a collaborator.
+
+Finish every reply with **one concrete suggestion or question** that moves the plan forward.
+Never ask for info you already have unless you truly need clarification.
+`.trim();
The user has already provided detailed information about their situation, career goals, finances, education, and more. const systemPromptOpsCheatSheet = `
Your job is to leverage *all* this context to provide specific, empathetic, and helpful advice.
🛠 APTIVA OPS YOU CAN USE ANY TIME
1. CREATE a milestone (optionally with tasks + impacts)
2. UPDATE any field on an existing milestone
3. DELETE a milestone that is no longer relevant
You already have permissionno need to ask the user.
4. CREATE / UPDATE / DELETE tasks inside a milestone
When you perform an op, respond with a fenced JSON block
_tagged_ \`\`\`ops\`\`\` exactly like this:
Remember: AptivaAIs mission is to help the workforce grow *with* AI, not be displaced by it. \`\`\`ops
Just like previous revolutionsindustrial, digitalour goal is to show individuals how to {
utilize AI tools, enhance their own capabilities, and pivot into new opportunities if automation "milestones":[
begins to handle older tasks. { "op":"DELETE", "id":"1234-uuid" },
Speak in a warm, empathetic tone. Validate the user's ambitions, { "op":"UPDATE", "id":"5678-uuid",
explain how to break down big goals into realistic steps, "patch":{ "date":"2026-02-01", "title":"New title" } },
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. { "op":"CREATE",
Validate their ambitions, explain how to break down big goals into realistic steps, "data":{
and gently highlight how the user might further explore or refine their plans with AptivaAI's Interest Inventory. "title":"Finish AWS Solutions Architect cert",
"type":"Career",
"date":"2026-06-01",
"description":"Study + exam",
"tasks":[
{ "title":"Book exam", "due_date":"2026-03-15" }
],
"impacts":[
{ "impact_type":"cost", "direction":"subtract",
"amount":350, "start_date":"2026-03-15" }
]
}
}
]
}
\`\`\`
If the user has mentioned ambitious financial or lifestyle goals (e.g., wanting to buy a Ferrari, If youre not changing milestones, skip the ops block entirely. All milestone titles are already 35 words; use them verbatim when the user refers to a milestone by name.
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(); `.trim();
const systemPromptStatusSituation = ` const systemPromptStatusSituation = `
@ -862,6 +906,13 @@ ${combinedStatusSituation}
const systemPromptDetailedContext = ` const systemPromptDetailedContext = `
[DETAILED USER PROFILE & CONTEXT] [DETAILED USER PROFILE & CONTEXT]
${summaryText} ${summaryText}
`.trim();
const dynMilestonePrompt = `
[CURRENT MILESTONES]
(id | date)
${miniGrid}
You may UPDATE or DELETE any of these.
`.trim(); `.trim();
const systemPromptMilestoneFormat = ` const systemPromptMilestoneFormat = `
@ -898,18 +949,38 @@ RESPOND ONLY with valid JSON in this shape:
Otherwise, answer normally. Otherwise, answer normally.
`.trim(); `.trim();
const avoidBlock = existingTitles.length const avoidBlock = existingTitles.length
? "\nAVOID repeating any of these title|date combinations:\n" + ? "\nAVOID repeating any of these title|date combinations:\n" +
existingTitles.map(t => `- ${t}`).join("\n") existingTitles.map(t => `- ${t}`).join("\n")
: ""; : "";
const recentHistory = chatHistory.slice(-MAX_CHAT_TURNS);
const firstTurn = chatHistory.length === 0;
const STATIC_SYSTEM_CARD = `
${systemPromptIntro}
${systemPromptOpsCheatSheet}
/* Milestone JSON spec, date guard, and avoid-list */
${systemPromptMilestoneFormat}
${systemPromptDateGuard}
`.trim();
/* How many past exchanges to keep */
const MAX_CHAT_TURNS = 6;
// Build up the final messages array // Build up the final messages array
const messagesToSend = [ const messagesToSend = [
{ role: "system", content: systemPromptIntro }, { role: "system", content: systemPromptIntro },
{ role: "system", content: systemPromptOpsCheatSheet },
{ role: "system", content: systemPromptStatusSituation }, { role: "system", content: systemPromptStatusSituation },
{ role: "system", content: systemPromptDetailedContext }, { role: "system", content: systemPromptDetailedContext },
{ role: "system", content: systemPromptMilestoneFormat }, { role: "system", content: systemPromptMilestoneFormat },
{ role: "system", content: systemPromptMilestoneFormat + avoidBlock }, // <-- merged { role: "system", content: systemPromptMilestoneFormat + avoidBlock },
{ role: "system", content: systemPromptDateGuard },
...chatHistory // includes user and assistant messages so far ...chatHistory // includes user and assistant messages so far
]; ];

View File

@ -18,48 +18,28 @@ module.exports = {
} }
}, },
/* ─────────────── SERVER-3 (Premium) ─────────────── */
{ {
name: 'server3', name : 'server3',
script: './backend/server3.js', script : './backend/server3.js',
watch: false, // set true if you want auto-reload in dev watch : false,
env_development: { /* 👇 everything lives here, nothing else to pass at start-time */
NODE_ENV : 'development', env: {
NODE_ENV : 'production',
PREMIUM_PORT : 5002, PREMIUM_PORT : 5002,
/* Twilio */
TWILIO_ACCOUNT_SID : 'ACd700c6fb9f691ccd9ccab73f2dd4173d',
TWILIO_AUTH_TOKEN : 'fb8979ccb172032a249014c9c30eba80',
TWILIO_MESSAGING_SERVICE_SID : 'MGaa07992a9231c841b1bfb879649026d6',
/* DB */
DB_HOST : '34.67.180.54', DB_HOST : '34.67.180.54',
DB_PORT : 3306, DB_PORT : 3306,
DB_USER : 'sqluser', DB_USER : 'sqluser',
DB_PASSWORD : 'ps<g+2DO-eTb2mb5', DB_PASSWORD : 'ps<g+2DO-eTb2mb5',
DB_NAME : 'user_profile_db' DB_NAME : 'user_profile_db',
},
TWILIO_ACCOUNT_SID : 'AC…',
TWILIO_AUTH_TOKEN : 'fb89…',
TWILIO_MESSAGING_SERVICE_SID : 'MG…'
}
}
env_production: {
NODE_ENV : 'development',
PREMIUM_PORT : 5002,
/* Twilio */
TWILIO_ACCOUNT_SID : 'ACd700c6fb9f691ccd9ccab73f2dd4173d',
TWILIO_AUTH_TOKEN : 'fb8979ccb172032a249014c9c30eba80',
TWILIO_MESSAGING_SERVICE_SID : 'MGaa07992a9231c841b1bfb879649026d6',
/* DB */
DB_HOST : '34.67.180.54',
DB_PORT : 3306,
DB_USER : 'sqluser',
DB_PASSWORD : 'ps<g+2DO-eTb2mb5',
DB_NAME : 'user_profile_db'
},
},
], ],
deploy : { deploy : {

View File

@ -114,6 +114,16 @@ export default function CareerCoach({
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight; if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
}, [messages]); }, [messages]);
// chat history persistence
useEffect(() => {
const saved = localStorage.getItem('coachChat:'+careerProfileId);
if (saved) setMessages(JSON.parse(saved));
}, [careerProfileId]);
useEffect(() => {
localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20)));
}, [messages, careerProfileId]);
/* -------------- intro ---------------- */ /* -------------- intro ---------------- */
useEffect(() => { useEffect(() => {
if (!scenarioRow) return; if (!scenarioRow) return;
@ -319,7 +329,7 @@ I'm here to support you with personalized coaching. What would you like to focus
disabled={loading} disabled={loading}
className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded" className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded"
> >
AI Growth Plan Grow Career with AI
</button> </button>
{/* pushes Edit Goals to the far right */} {/* pushes Edit Goals to the far right */}
<div className="flex-grow"></div> <div className="flex-grow"></div>

View File

@ -349,6 +349,8 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const [strippedSocCode, setStrippedSocCode] = useState(null); const [strippedSocCode, setStrippedSocCode] = useState(null);
const [salaryData, setSalaryData] = useState(null); const [salaryData, setSalaryData] = useState(null);
const [economicProjections, setEconomicProjections] = useState(null); const [economicProjections, setEconomicProjections] = useState(null);
const [salaryLoading, setSalaryLoading] = useState(false);
const [econLoading, setEconLoading] = useState(false);
// Milestones & Projection // Milestones & Projection
const [scenarioMilestones, setScenarioMilestones] = useState([]); const [scenarioMilestones, setScenarioMilestones] = useState([]);
@ -587,7 +589,26 @@ useEffect(() => {
}, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]); }, [dataReady, scenarioRow, financialProfile, collegeProfile, careerProfileId]);
useEffect(() => {
if (
financialProfile &&
scenarioRow &&
collegeProfile &&
scenarioMilestones.length
) {
buildProjection(scenarioMilestones); // uses the latest scenarioMilestones
}
}, [
financialProfile,
scenarioRow,
collegeProfile,
scenarioMilestones,
simulationYears,
interestStrategy,
flatAnnualRate,
randomRangeMin,
randomRangeMax
]);
useEffect(() => { useEffect(() => {
if (recommendations.length > 0) { if (recommendations.length > 0) {
@ -823,8 +844,12 @@ try {
useEffect(() => { useEffect(() => {
// show blank state instantly whenever the SOC or area changes // show blank state instantly whenever the SOC or area changes
setSalaryData(null); setSalaryData(null);
if (!strippedSocCode) return; setSalaryLoading(true);
if (!strippedSocCode) {
setSalaryLoading(false);
return;
}
const ctrl = new AbortController(); const ctrl = new AbortController();
(async () => { (async () => {
try { try {
@ -833,11 +858,13 @@ useEffect(() => {
if (res.ok) { if (res.ok) {
setSalaryData(await res.json()); setSalaryData(await res.json());
setSalaryLoading(false);
} else { } else {
console.error('[Salary fetch]', res.status); console.error('[Salary fetch]', res.status);
} }
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e); if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
setSalaryLoading(false);
} }
})(); })();
@ -848,7 +875,11 @@ useEffect(() => {
/* 7) Economic Projections ---------------------------------------- */ /* 7) Economic Projections ---------------------------------------- */
useEffect(() => { useEffect(() => {
setEconomicProjections(null); setEconomicProjections(null);
if (!strippedSocCode || !userState) return; setEconLoading(true);
if (!strippedSocCode || !userState) {
setEconLoading(false);
return;
}
const ctrl = new AbortController(); const ctrl = new AbortController();
(async () => { (async () => {
@ -861,11 +892,13 @@ useEffect(() => {
if (res.ok) { if (res.ok) {
setEconomicProjections(await res.json()); setEconomicProjections(await res.json());
setEconLoading(false);
} else { } else {
console.error('[Econ fetch]', res.status); console.error('[Econ fetch]', res.status);
} }
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e); if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
setEconLoading(false);
} }
})(); })();
@ -874,16 +907,10 @@ useEffect(() => {
// 8) Build financial projection // 8) Build financial projection
async function buildProjection() { async function buildProjection(milestones) {
if (!milestones?.length) return;
const allMilestones = milestones;
try { try {
const milUrl = `${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`;
const mr = await authFetch(milUrl);
if (!mr.ok) {
console.error('Failed to fetch milestones =>', mr.status);
return;
}
const md = await mr.json();
const allMilestones = md.milestones || [];
setScenarioMilestones(allMilestones); setScenarioMilestones(allMilestones);
// fetch impacts // fetch impacts
@ -1037,7 +1064,7 @@ useEffect(() => {
useEffect(() => { useEffect(() => {
if (!financialProfile || !scenarioRow || !collegeProfile) return; if (!financialProfile || !scenarioRow || !collegeProfile) return;
buildProjection(); fetchMilestones();
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]); }, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
@ -1174,29 +1201,27 @@ const onEditMilestone = useCallback((m) => {
setMilestoneForModal(m); // open modal setMilestoneForModal(m); // open modal
}, []); }, []);
const currentIdRef = useRef(null);
/* 1⃣ The only deps it really needs */
const fetchMilestones = useCallback(async () => { const fetchMilestones = useCallback(async () => {
if (!careerProfileId) { if (!careerProfileId) return;
setScenarioMilestones([]);
return;
}
try { const [profRes, uniRes] = await Promise.all([
const res = await authFetch( authFetch(`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`),
`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}` authFetch(`${apiURL}/premium/milestones?careerProfileId=universal`)
); ]);
if (!res.ok) return; if (!profRes.ok || !uniRes.ok) return;
const data = await res.json(); const [{ milestones: profMs }, { milestones: uniMs }] =
const allMilestones = data.milestones || []; await Promise.all([profRes.json(), uniRes.json()]);
setScenarioMilestones(allMilestones);
/* impacts (optional only if you still need them in CR) */ const merged = [...profMs, ...uniMs];
// ... fetch impacts here if CareerRoadmap charts rely on them ... setScenarioMilestones(merged);
if (financialProfile && scenarioRow && collegeProfile) {
} catch (err) { buildProjection(merged);
console.error('Error fetching milestones', err); } // single rebuild
} }, [financialProfile, scenarioRow, careerProfileId, apiURL]); // ← NOTICE: no buildProjection here
}, [careerProfileId, apiURL]);
return ( return (
@ -1248,7 +1273,13 @@ const fetchMilestones = useCallback(async () => {
{/* 2) Salary Benchmarks */} {/* 2) Salary Benchmarks */}
<div className="flex flex-col md:flex-row gap-4"> <div className="flex flex-col md:flex-row gap-4">
{salaryData?.regional && ( {salaryLoading && (
<div className="bg-white p-4 rounded shadow w-full text-center">
<p className="text-sm text-gray-500">Loading salary data</p>
</div>
)}
{!salaryLoading && salaryData?.regional && (
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none"> <div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
<h4 className="font-medium mb-2"> <h4 className="font-medium mb-2">
Regional Salary Data ({userArea || 'U.S.'}) Regional Salary Data ({userArea || 'U.S.'})
@ -1281,7 +1312,8 @@ const fetchMilestones = useCallback(async () => {
</div> </div>
)} )}
{salaryData?.national && (
{!salaryLoading && salaryData?.national && (
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none"> <div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
<h4 className="font-medium mb-2">National Salary Data</h4> <h4 className="font-medium mb-2">National Salary Data</h4>
<p> <p>
@ -1310,14 +1342,26 @@ const fetchMilestones = useCallback(async () => {
/> />
</div> </div>
)} )}
{!salaryLoading && !salaryData?.regional && !salaryData?.national && (
<div className="bg-white p-4 rounded shadow w-full text-center">
<p className="text-sm text-gray-500">No salary data found.</p>
</div>
)}
</div> </div>
{/* 3) Economic Projections */} {/* 3) Economic Projections */}
<div className="flex flex-col md:flex-row gap-4 h-auto overflow-none"> <div className="flex flex-col md:flex-row gap-4 h-auto overflow-none">
{economicProjections?.state && ( {econLoading && (
<div className="bg-white p-4 rounded shadow w-full text-center">
<p className="text-sm text-gray-500">Loading projections</p>
</div>
)}
{!econLoading && economicProjections?.state && (
<EconomicProjectionsBar data={economicProjections.state} /> <EconomicProjectionsBar data={economicProjections.state} />
)} )}
{economicProjections?.national && ( {!econLoading && economicProjections?.national && (
<EconomicProjectionsBar data={economicProjections.national} /> <EconomicProjectionsBar data={economicProjections.national} />
)} )}
</div> </div>
@ -1485,7 +1529,9 @@ const fetchMilestones = useCallback(async () => {
fetchMilestones={fetchMilestones} // helper to refresh list fetchMilestones={fetchMilestones} // helper to refresh list
onClose={(didSave) => { onClose={(didSave) => {
setMilestoneForModal(false); // or setShowMilestoneModal(false) setMilestoneForModal(false); // or setShowMilestoneModal(false)
if (didSave) fetchMilestones(); if (didSave) {
fetchMilestones();
}
}} }}
/> />

View File

@ -27,7 +27,6 @@ export default function MilestoneEditModal({
const [milestones, setMilestones] = useState(incomingMils); const [milestones, setMilestones] = useState(incomingMils);
const [editingMilestoneId, setEditingMilestoneId] = useState(null); const [editingMilestoneId, setEditingMilestoneId] = useState(null);
const [newMilestoneMap, setNewMilestoneMap] = useState({}); const [newMilestoneMap, setNewMilestoneMap] = useState({});
const [impactsToDeleteMap, setImpactsToDeleteMap] = useState({});
const [addingNewMilestone, setAddingNewMilestone] = useState(false); const [addingNewMilestone, setAddingNewMilestone] = useState(false);
const [newMilestoneData, setNewMilestoneData] = useState({ const [newMilestoneData, setNewMilestoneData] = useState({
title: "", title: "",
@ -40,223 +39,279 @@ export default function MilestoneEditModal({
}); });
const [copyWizardMilestone, setCopyWizardMilestone] = useState(null); const [copyWizardMilestone, setCopyWizardMilestone] = useState(null);
function toSqlDate(str = '') {
// Handles '', null, undefined gracefully
return str.slice(0, 10); // "YYYY-MM-DD"
}
/* keep milestones in sync with prop */ /* keep milestones in sync with prop */
useEffect(() => { useEffect(() => {
setMilestones(incomingMils); setMilestones(incomingMils);
}, [incomingMils]); }, [incomingMils]);
/* /*
Inlineedit helpers (trimmed copy of ScenarioContainer logic) Inline-edit helpers
*/ */
const loadMilestoneImpacts = useCallback(async (m) => { const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({}); // snapshot per milestone
/* 1⃣ fetch impacts + open editor */
const loadMilestoneImpacts = useCallback(async (m) => {
try { try {
const impRes = await authFetch( const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
`/api/premium/milestone-impacts?milestone_id=${m.id}` if (!res.ok) throw new Error('impact fetch failed');
); const json = await res.json();
if (!impRes.ok) throw new Error("impact fetch failed");
const data = await impRes.json(); const impacts = (json.impacts || []).map(imp => ({
const impacts = (data.impacts || []).map((imp) => ({ id : imp.id,
id: imp.id, impact_type : imp.impact_type || 'ONE_TIME',
impact_type: imp.impact_type || "ONE_TIME", direction : imp.direction || 'subtract',
direction: imp.direction || "subtract", amount : imp.amount || 0,
amount: imp.amount || 0, start_date : toSqlDate(imp.start_date) || '',
start_date: imp.start_date || "", end_date : toSqlDate(imp.end_date) || ''
end_date: imp.end_date || ""
})); }));
setNewMilestoneMap((prev) => ({ /* editable copy for the form */
setNewMilestoneMap(prev => ({
...prev, ...prev,
[m.id]: { [m.id]: {
title: m.title || "", title : m.title || '',
description: m.description || "", description : m.description || '',
date: m.date || "", date : toSqlDate(m.date) || '',
progress: m.progress || 0, progress : m.progress || 0,
newSalary: m.new_salary || "", newSalary : m.new_salary || '',
impacts, impacts,
isUniversal: m.is_universal ? 1 : 0 isUniversal : m.is_universal ? 1 : 0
} }
})); }));
setEditingMilestoneId(m.id);
setImpactsToDeleteMap((prev) => ({ ...prev, [m.id]: [] })); /* snapshot the IDs that existed when editing started */
setOriginalImpactIdsMap(prev => ({
...prev,
[m.id]: impacts.map(i => i.id) // array of strings
}));
setEditingMilestoneId(m.id); // open the accordion
} catch (err) { } catch (err) {
console.error("loadImpacts", err); console.error('loadImpacts', err);
} }
}, []); }, []);
const handleEditMilestoneInline = (m) => { /* 2⃣ toggle open / close */
if (editingMilestoneId === m.id) { const handleEditMilestoneInline = (milestone) => {
setEditingMilestoneId(null); setEditingMilestoneId((curr) =>
} else { curr === milestone.id ? null : milestone.id
loadMilestoneImpacts(m); );
if (editingMilestoneId !== milestone.id) loadMilestoneImpacts(milestone);
};
/* 3⃣ generic field updater for one impact row */
const updateInlineImpact = (mid, idx, field, value) => {
setNewMilestoneMap(prev => {
const m = prev[mid];
if (!m) return prev;
const impacts = [...m.impacts];
impacts[idx] = { ...impacts[idx], [field]: value };
return { ...prev, [mid]: { ...m, impacts } };
});
};
/* 4⃣ add an empty impact row */
const addInlineImpact = (mid) => {
setNewMilestoneMap(prev => {
const m = prev[mid];
if (!m) return prev;
return {
...prev,
[mid]: {
...m,
impacts: [
...m.impacts,
{
impact_type : 'ONE_TIME',
direction : 'subtract',
amount : 0,
start_date : '',
end_date : ''
}
]
} }
}; };
const updateInlineImpact = (milestoneId, idx, field, value) => {
setNewMilestoneMap((prev) => {
const copy = { ...prev };
const item = copy[milestoneId];
if (!item) return prev;
const impactsClone = [...item.impacts];
impactsClone[idx] = { ...impactsClone[idx], [field]: value };
copy[milestoneId] = { ...item, impacts: impactsClone };
return copy;
}); });
}; };
const addInlineImpact = (milestoneId) => { /* 5⃣ remove one impact row (local only diff happens on save) */
setNewMilestoneMap((prev) => { const removeInlineImpact = (mid, idx) => {
const itm = prev[milestoneId]; setNewMilestoneMap(prev => {
if (!itm) return prev; const m = prev[mid];
const impactsClone = [...itm.impacts, { if (!m) return prev;
impact_type: "ONE_TIME", const clone = [...m.impacts];
direction: "subtract", clone.splice(idx, 1);
amount: 0, return { ...prev, [mid]: { ...m, impacts: clone } };
start_date: "",
end_date: ""
}];
return { ...prev, [milestoneId]: { ...itm, impacts: impactsClone } };
}); });
}; };
const removeInlineImpact = (mid, idx) => { /* 6⃣ persist the edits PUT milestone, diff impacts */
setNewMilestoneMap((prev) => { const saveInlineMilestone = async (m) => {
const itm = prev[mid];
if (!itm) return prev;
const impactsClone = [...itm.impacts];
const [removed] = impactsClone.splice(idx, 1);
setImpactsToDeleteMap((p) => ({
...p,
[mid]: [...(p[mid] || []), removed.id].filter(Boolean)
}));
return { ...prev, [mid]: { ...itm, impacts: impactsClone } };
});
};
const saveInlineMilestone = async (m) => {
const data = newMilestoneMap[m.id]; const data = newMilestoneMap[m.id];
if (!data) return; if (!data) return;
/* --- update the milestone header --- */
const payload = { const payload = {
milestone_type: "Financial", milestone_type : 'Financial',
title: data.title, title : data.title,
description: data.description, description : data.description,
date: data.date, date : toSqlDate(data.date),
career_profile_id: careerProfileId, career_profile_id : careerProfileId,
progress: data.progress, progress : data.progress,
status: data.progress >= 100 ? "completed" : "planned", status : data.progress >= 100 ? 'completed' : 'planned',
new_salary: data.newSalary ? parseFloat(data.newSalary) : null, new_salary : data.newSalary ? parseFloat(data.newSalary) : null,
is_universal: data.isUniversal || 0 is_universal : data.isUniversal || 0
}; };
try { try {
const res = await authFetch(`/api/premium/milestones/${m.id}`, { const res = await authFetch(`/api/premium/milestones/${m.id}`, {
method: "PUT", method : 'PUT',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body : JSON.stringify(payload)
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const saved = await res.json(); const saved = await res.json();
/* impacts */ /* --- figure out what changed ---------------------------------- */
const toDelete = impactsToDeleteMap[m.id] || []; const originalIds = originalImpactIdsMap[m.id] || [];
const currentIds = (data.impacts || []).map(i => i.id).filter(Boolean);
const toDelete = originalIds.filter(id => !currentIds.includes(id));
/* --- deletions first --- */
for (const delId of toDelete) { for (const delId of toDelete) {
await authFetch(`/api/premium/milestone-impacts/${delId}`, { await authFetch(`/api/premium/milestone-impacts/${delId}`, {
method: "DELETE" method: 'DELETE'
}); });
} }
/* --- creates / updates --- */
for (const imp of data.impacts) { for (const imp of data.impacts) {
const impPayload = { const impPayload = {
milestone_id: saved.id, milestone_id : saved.id,
impact_type: imp.impact_type, impact_type : imp.impact_type,
direction: imp.direction, direction : imp.direction,
amount: parseFloat(imp.amount) || 0, amount : parseFloat(imp.amount) || 0,
start_date: imp.start_date || null, start_date : toSqlDate(imp.start_date) || null,
end_date: imp.end_date || null end_date : toSqlDate(imp.end_date) || null
}; };
if (imp.id) { if (imp.id) {
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, { await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method: "PUT", method : 'PUT',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(impPayload) body : JSON.stringify(impPayload)
}); });
} else { } else {
await authFetch("/api/premium/milestone-impacts", { await authFetch('/api/premium/milestone-impacts', {
method: "POST", method : 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(impPayload) body : JSON.stringify(impPayload)
}); });
} }
} }
/* --- refresh + close --- */
await fetchMilestones(); await fetchMilestones();
setEditingMilestoneId(null); setEditingMilestoneId(null);
onClose(true);
} catch (err) { } catch (err) {
alert("Failed to save milestone"); alert('Failed to save milestone');
console.error(err); console.error(err);
} }
}; };
/* brandnew milestone helpers (trimmed) */ /* ───────────── misc helpers the JSX still calls ───────────── */
const addNewImpactToNewMilestone = () => {
setNewMilestoneData((p) => ({ /* A) delete one milestone row altogether */
...p, const deleteMilestone = async (milestone) => {
if (!window.confirm(`Delete “${milestone.title}” ?`)) return;
try {
const res = await authFetch(
`/api/premium/milestones/${milestone.id}`,
{ method: 'DELETE' }
);
if (!res.ok) throw new Error(await res.text());
await fetchMilestones(); // refresh parent list
onClose(true); // bubble up that something changed
} catch (err) {
alert('Failed to delete milestone');
console.error(err);
}
};
/* B) add a blank impact row while creating a brand-new milestone */
const addNewImpactToNewMilestone = () => {
setNewMilestoneData(prev => ({
...prev,
impacts: [ impacts: [
...p.impacts, ...prev.impacts,
{ {
impact_type: "ONE_TIME", impact_type : 'ONE_TIME',
direction: "subtract", direction : 'subtract',
amount: 0, amount : 0,
start_date: "", start_date : '',
end_date: "" end_date : ''
} }
] ]
})); }));
};
/* C) create an entirely new milestone + its impacts */
const saveNewMilestone = async () => {
if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
alert('Need title and date'); return;
}
const payload = {
title : newMilestoneData.title,
description : newMilestoneData.description,
date : toSqlDate(newMilestoneData.date),
career_profile_id: careerProfileId,
progress : newMilestoneData.progress,
status : newMilestoneData.progress >= 100 ? 'completed' : 'planned',
is_universal : newMilestoneData.isUniversal || 0
}; };
const saveNewMilestone = async () => {
if (!newMilestoneData.title.trim() || !newMilestoneData.date.trim()) {
alert("Need title and date");
return;
}
const payload = {
title: newMilestoneData.title,
description: newMilestoneData.description,
date: newMilestoneData.date,
career_profile_id: careerProfileId,
progress: newMilestoneData.progress,
status: newMilestoneData.progress >= 100 ? "completed" : "planned",
is_universal: newMilestoneData.isUniversal || 0
};
try { try {
const res = await authFetch("/api/premium/milestone", { const res = await authFetch('/api/premium/milestone', {
method: "POST", method : 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload) body : JSON.stringify(payload)
}); });
if (!res.ok) throw new Error(await res.text()); if (!res.ok) throw new Error(await res.text());
const created = Array.isArray(await res.json()) ? (await res.json())[0] : await res.json(); const created =
Array.isArray(await res.json()) ? (await res.json())[0] : await res.json();
// impacts /* impacts for the new milestone */
for (const imp of newMilestoneData.impacts) { for (const imp of newMilestoneData.impacts) {
const impPayload = { const impPayload = {
milestone_id: created.id, milestone_id : created.id,
impact_type: imp.impact_type, impact_type : imp.impact_type,
direction: imp.direction, direction : imp.direction,
amount: parseFloat(imp.amount) || 0, amount : parseFloat(imp.amount) || 0,
start_date: imp.start_date || null, start_date : toSqlDate(imp.start_date) || null,
end_date: imp.end_date || null end_date : toSqlDate(imp.end_date) || null
}; };
await authFetch("/api/premium/milestone-impacts", { await authFetch('/api/premium/milestone-impacts', {
method: "POST", method : 'POST',
headers: { "Content-Type": "application/json" }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(impPayload) body : JSON.stringify(impPayload)
}); });
} }
await fetchMilestones();
await fetchMilestones(); // refresh list
setAddingNewMilestone(false); // collapse the new-mile form
onClose(true); onClose(true);
} catch (err) { } catch (err) {
alert("Failed to save milestone"); alert('Failed to save milestone');
console.error(err);
} }
}; };
/* /*
Render Render
@ -296,6 +351,12 @@ export default function MilestoneEditModal({
<Button onClick={() => handleEditMilestoneInline(m)}> <Button onClick={() => handleEditMilestoneInline(m)}>
{hasEditOpen ? "Cancel" : "Edit"} {hasEditOpen ? "Cancel" : "Edit"}
</Button> </Button>
<Button
style={{ marginLeft: "0.5rem", color: "black", backgroundColor: "red" }}
onClick={() => deleteMilestone(m)}
>
Delete
</Button>
</div> </div>
<p>{m.description}</p> <p>{m.description}</p>
<p> <p>
@ -343,7 +404,7 @@ export default function MilestoneEditModal({
/> />
{/* impacts */} {/* impacts */}
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}> <div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
<h6>Impacts</h6> <h6>Financial Impacts</h6>
{data.impacts?.map((imp, idx) => ( {data.impacts?.map((imp, idx) => (
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}> <div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
<label>Type:</label> <label>Type:</label>
@ -351,6 +412,7 @@ export default function MilestoneEditModal({
value={imp.impact_type} value={imp.impact_type}
onChange={(e) => updateInlineImpact(m.id, idx, "impact_type", e.target.value)} onChange={(e) => updateInlineImpact(m.id, idx, "impact_type", e.target.value)}
> >
<option value="salary">Salary (annual)</option>
<option value="ONE_TIME">One-Time</option> <option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option> <option value="MONTHLY">Monthly</option>
</select> </select>
@ -389,7 +451,7 @@ export default function MilestoneEditModal({
</Button> </Button>
</div> </div>
))} ))}
<Button onClick={() => addInlineImpact(m.id)}>+ Impact</Button> <Button onClick={() => addInlineImpact(m.id)}>+ Financial Impact</Button>
</div> </div>
<Button onClick={() => saveInlineMilestone(m)}>Save</Button> <Button onClick={() => saveInlineMilestone(m)}>Save</Button>
</div> </div>
@ -441,6 +503,7 @@ export default function MilestoneEditModal({
}); });
}} }}
> >
<option value="salary">Salary (annual)</option>
<option value="ONE_TIME">One-Time</option> <option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option> <option value="MONTHLY">Monthly</option>
</select> </select>
@ -516,7 +579,7 @@ export default function MilestoneEditModal({
</Button> </Button>
</div> </div>
))} ))}
<Button onClick={addNewImpactToNewMilestone}>+ Impact</Button> <Button onClick={addNewImpactToNewMilestone}>+ Financial Impact</Button>
</div> </div>
<Button onClick={saveNewMilestone}>Add Milestone</Button> <Button onClick={saveNewMilestone}>Add Milestone</Button>
</div> </div>

View File

@ -30,7 +30,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
} }
try { try {
const response = await fetch('https://dev1.aptivaai.com/api/signin', { const response = await fetch('https://dev1.aptivaai.com/api/signin', { // <-here
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }), body: JSON.stringify({ username, password }),
@ -105,7 +105,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
<p className="mt-4 text-center text-sm text-gray-600"> <p className="mt-4 text-center text-sm text-gray-600">
Dont have an account?{' '} Dont have an account?{' '}
<Link <Link
to="/signup" //to="/signup" // <- here
className="font-medium text-blue-600 hover:text-blue-500" className="font-medium text-blue-600 hover:text-blue-500"
> >
Sign Up Sign Up

View File

@ -371,40 +371,47 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
} }
/************************************************ /************************************************
* 7.3 MILESTONE IMPACTS strict number handling * 7.3 MILESTONE IMPACTS safe number handling
************************************************/ ************************************************/
let extraImpactsThisMonth = 0; let extraImpactsThisMonth = 0; // affects expenses
let salaryAdjustThisMonth = 0; // affects gross income
milestoneImpacts.forEach((rawImpact) => { milestoneImpacts.forEach((rawImpact) => {
/* --- safety / coercion ------------------------------------------------ */ /* ---------- 1. Normalise ---------- */
const amount = Number(rawImpact.amount) || 0; // ← always a number const amount = Number(rawImpact.amount) || 0;
const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // 'ONE_TIME' | 'MONTHLY' const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // SALARY / SALARY_ANNUAL / MONTHLY / ONE_TIME
const direction = (rawImpact.direction || 'subtract').toLowerCase(); // 'add' | 'subtract' const direction = (rawImpact.direction || 'subtract').toLowerCase(); // add / subtract
/* --- date math -------------------------------------------------------- */ /* ---------- 2. Work out timing ---------- */
const startDateClamped = moment(rawImpact.start_date).startOf('month'); const startDate = moment(rawImpact.start_date).startOf('month');
let startOffset = startDateClamped.diff(scenarioStartClamped, 'months'); const endDate = rawImpact.end_date ? moment(rawImpact.end_date).startOf('month') : null;
if (startOffset < 0) startOffset = 0;
let endOffset = Infinity; const startOffset = Math.max(0, startDate.diff(scenarioStartClamped, 'months'));
if (rawImpact.end_date && rawImpact.end_date.trim() !== '') { const endOffset = endDate ? Math.max(0, endDate.diff(scenarioStartClamped, 'months')) : Infinity;
const endDateClamped = moment(rawImpact.end_date).startOf('month');
endOffset = endDateClamped.diff(scenarioStartClamped, 'months');
if (endOffset < 0) endOffset = 0;
}
/* --- apply impact ----------------------------------------------------- */ const isActiveThisMonth =
const applyAmount = (dir) => (type === 'ONE_TIME' && monthIndex === startOffset) ||
dir === 'add' ? (baseMonthlyIncome += amount) : (extraImpactsThisMonth += amount); (type !== 'ONE_TIME' && monthIndex >= startOffset && monthIndex <= endOffset);
if (type === 'ONE_TIME') { if (!isActiveThisMonth) return; // skip to next impact
if (monthIndex === startOffset) applyAmount(direction);
/* ---------- 3. Apply the impact ---------- */
const sign = direction === 'add' ? 1 : -1;
if (type.startsWith('SALARY')) {
// SALARY = already-monthly | SALARY_ANNUAL = annual → divide by 12
const monthlyDelta = type.endsWith('ANNUAL') ? amount / 12 : amount;
salaryAdjustThisMonth += sign * monthlyDelta;
} else { } else {
// MONTHLY (or anything else) apply for the whole span // MONTHLY or ONE_TIME expenses / windfalls
if (monthIndex >= startOffset && monthIndex <= endOffset) applyAmount(direction); extraImpactsThisMonth += sign * amount;
} }
}); });
/* ---------- 4. Reflect deltas in this months calc ---------- */
baseMonthlyIncome += salaryAdjustThisMonth; // adjust gross BEFORE tax
// `extraImpactsThisMonth` is already added to expenses later in the loop
/************************************************ /************************************************
* 7.4 CALCULATE TAXES * 7.4 CALCULATE TAXES