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
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const pool = mysql.createPool({
host : process.env.DB_HOST || '127.0.0.1',
port : process.env.DB_PORT || 3306,
user : process.env.DB_USER || 'root',
password : process.env.DB_PASSWORD || '',
database : process.env.DB_NAME || 'user_profile_db',
waitForConnections : true,
connectionLimit : 10,
...(process.env.DB_SOCKET ? { socketPath: process.env.DB_SOCKET } : {})
});
// 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',
port : process.env.DB_PORT || 3306,
user : process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
};
}
poolConfig.database = process.env.DB_NAME || 'user_profile_db';
poolConfig.waitForConnections = true;
poolConfig.connectionLimit = 10;
export default mysql.createPool(poolConfig);
export default pool;

View File

@ -1,35 +1,31 @@
//
// server3.js - MySQL Version
//
// ─── server3.js ────────────────────────────────────────────────────────────
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 cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import path from 'path';
import fs from 'fs/promises';
import multer from 'multer';
import fetch from "node-fetch";
import fetch from 'node-fetch';
import mammoth from 'mammoth';
import { fileURLToPath } from 'url';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist';
import db from './config/mysqlPool.js'; // Adjust path as necessary
import db from './config/mysqlPool.js';
import './jobs/reminderCron.js';
import OpenAI from 'openai';
import Fuse from 'fuse.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";
@ -49,6 +45,7 @@ function internalFetch(req, url, opts = {}) {
}
// 2) Basic middlewares
app.use(helmet());
app.use(express.json({ limit: '5mb' }));
@ -73,6 +70,8 @@ const authenticatePremiumUser = (req, res, next) => {
}
};
const pool = db;
/* ------------------------------------------------------------------
CAREER PROFILE ENDPOINTS
------------------------------------------------------------------ */
@ -513,17 +512,26 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
} = req.body;
let existingTitles = [];
let miniGrid = "-none-"; // slim grid
try {
const [rows] = await pool.query(
`SELECT title, DATE_FORMAT(date,'%Y-%m-%d') AS d
FROM milestones
WHERE user_id = ? AND career_profile_id = ?`,
`SELECT id, DATE_FORMAT(date,'%Y-%m-%d') AS d, title
FROM milestones
WHERE user_id = ? AND career_profile_id = ?`,
[req.id, scenarioRow.id]
);
existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`);
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);
console.error("Could not fetch existing milestones ", e);
}
// ------------------------------------------------
// 1. Helper Functions
// ------------------------------------------------
@ -824,34 +832,70 @@ ${econText}
// 5. Construct System-Level Prompts
// ------------------------------------------------
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.
Your job is to leverage *all* this context to provide specific, empathetic, and helpful advice.
const systemPromptOpsCheatSheet = `
🛠 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.
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
begins to handle older tasks.
\`\`\`ops
{
"milestones":[
{ "op":"DELETE", "id":"1234-uuid" },
Speak in a warm, empathetic tone. Validate the user's ambitions,
explain how to break down big goals into realistic steps,
and highlight how AI can serve as a *collaborative* tool rather than a rival.
{ "op":"UPDATE", "id":"5678-uuid",
"patch":{ "date":"2026-02-01", "title":"New title" } },
Reference the user's location and any relevant experiences or ambitions they've shared.
Validate their ambitions, explain how to break down big goals into realistic steps,
and gently highlight how the user might further explore or refine their plans with AptivaAI's Interest Inventory.
{ "op":"CREATE",
"data":{
"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,
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.
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.
`.trim();
const systemPromptStatusSituation = `
@ -862,6 +906,13 @@ ${combinedStatusSituation}
const systemPromptDetailedContext = `
[DETAILED USER PROFILE & CONTEXT]
${summaryText}
`.trim();
const dynMilestonePrompt = `
[CURRENT MILESTONES]
(id | date)
${miniGrid}
You may UPDATE or DELETE any of these.
`.trim();
const systemPromptMilestoneFormat = `
@ -898,18 +949,38 @@ RESPOND ONLY with valid JSON in this shape:
Otherwise, answer normally.
`.trim();
const avoidBlock = existingTitles.length
? "\nAVOID repeating any of these title|date combinations:\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
const messagesToSend = [
{ role: "system", content: systemPromptIntro },
{ role: "system", content: systemPromptOpsCheatSheet },
{ role: "system", content: systemPromptStatusSituation },
{ role: "system", content: systemPromptDetailedContext },
{ 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
];

View File

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

View File

@ -114,6 +114,16 @@ export default function CareerCoach({
if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight;
}, [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 ---------------- */
useEffect(() => {
if (!scenarioRow) return;
@ -319,7 +329,7 @@ I'm here to support you with personalized coaching. What would you like to focus
disabled={loading}
className="bg-teal-600 hover:bg-teal-700 text-white px-3 py-1 rounded"
>
AI Growth Plan
Grow Career with AI
</button>
{/* pushes Edit Goals to the far right */}
<div className="flex-grow"></div>

View File

@ -349,6 +349,8 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const [strippedSocCode, setStrippedSocCode] = useState(null);
const [salaryData, setSalaryData] = useState(null);
const [economicProjections, setEconomicProjections] = useState(null);
const [salaryLoading, setSalaryLoading] = useState(false);
const [econLoading, setEconLoading] = useState(false);
// Milestones & Projection
const [scenarioMilestones, setScenarioMilestones] = useState([]);
@ -587,7 +589,26 @@ useEffect(() => {
}, [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(() => {
if (recommendations.length > 0) {
@ -823,8 +844,12 @@ try {
useEffect(() => {
// show blank state instantly whenever the SOC or area changes
setSalaryData(null);
if (!strippedSocCode) return;
setSalaryLoading(true);
if (!strippedSocCode) {
setSalaryLoading(false);
return;
}
const ctrl = new AbortController();
(async () => {
try {
@ -833,11 +858,13 @@ useEffect(() => {
if (res.ok) {
setSalaryData(await res.json());
setSalaryLoading(false);
} else {
console.error('[Salary fetch]', res.status);
}
} catch (e) {
if (e.name !== 'AbortError') console.error('[Salary fetch error]', e);
setSalaryLoading(false);
}
})();
@ -848,7 +875,11 @@ useEffect(() => {
/* 7) Economic Projections ---------------------------------------- */
useEffect(() => {
setEconomicProjections(null);
if (!strippedSocCode || !userState) return;
setEconLoading(true);
if (!strippedSocCode || !userState) {
setEconLoading(false);
return;
}
const ctrl = new AbortController();
(async () => {
@ -861,11 +892,13 @@ useEffect(() => {
if (res.ok) {
setEconomicProjections(await res.json());
setEconLoading(false);
} else {
console.error('[Econ fetch]', res.status);
}
} catch (e) {
if (e.name !== 'AbortError') console.error('[Econ fetch error]', e);
setEconLoading(false);
}
})();
@ -874,17 +907,11 @@ useEffect(() => {
// 8) Build financial projection
async function buildProjection() {
async function buildProjection(milestones) {
if (!milestones?.length) return;
const allMilestones = milestones;
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
const imPromises = allMilestones.map((m) =>
@ -1037,7 +1064,7 @@ useEffect(() => {
useEffect(() => {
if (!financialProfile || !scenarioRow || !collegeProfile) return;
buildProjection();
fetchMilestones();
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
@ -1174,29 +1201,27 @@ const onEditMilestone = useCallback((m) => {
setMilestoneForModal(m); // open modal
}, []);
const currentIdRef = useRef(null);
/* 1⃣ The only deps it really needs */
const fetchMilestones = useCallback(async () => {
if (!careerProfileId) {
setScenarioMilestones([]);
return;
}
if (!careerProfileId) return;
try {
const res = await authFetch(
`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`
);
if (!res.ok) return;
const [profRes, uniRes] = await Promise.all([
authFetch(`${apiURL}/premium/milestones?careerProfileId=${careerProfileId}`),
authFetch(`${apiURL}/premium/milestones?careerProfileId=universal`)
]);
if (!profRes.ok || !uniRes.ok) return;
const data = await res.json();
const allMilestones = data.milestones || [];
setScenarioMilestones(allMilestones);
const [{ milestones: profMs }, { milestones: uniMs }] =
await Promise.all([profRes.json(), uniRes.json()]);
/* impacts (optional only if you still need them in CR) */
// ... fetch impacts here if CareerRoadmap charts rely on them ...
} catch (err) {
console.error('Error fetching milestones', err);
}
}, [careerProfileId, apiURL]);
const merged = [...profMs, ...uniMs];
setScenarioMilestones(merged);
if (financialProfile && scenarioRow && collegeProfile) {
buildProjection(merged);
} // single rebuild
}, [financialProfile, scenarioRow, careerProfileId, apiURL]); // ← NOTICE: no buildProjection here
return (
@ -1248,7 +1273,13 @@ const fetchMilestones = useCallback(async () => {
{/* 2) Salary Benchmarks */}
<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">
<h4 className="font-medium mb-2">
Regional Salary Data ({userArea || 'U.S.'})
@ -1280,8 +1311,9 @@ const fetchMilestones = useCallback(async () => {
/>
</div>
)}
{salaryData?.national && (
{!salaryLoading && salaryData?.national && (
<div className="bg-white p-4 rounded shadow w-full h-auto overflow-none">
<h4 className="font-medium mb-2">National Salary Data</h4>
<p>
@ -1310,14 +1342,26 @@ const fetchMilestones = useCallback(async () => {
/>
</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>
{/* 3) Economic Projections */}
<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} />
)}
{economicProjections?.national && (
{!econLoading && economicProjections?.national && (
<EconomicProjectionsBar data={economicProjections.national} />
)}
</div>
@ -1485,7 +1529,9 @@ const fetchMilestones = useCallback(async () => {
fetchMilestones={fetchMilestones} // helper to refresh list
onClose={(didSave) => {
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 [editingMilestoneId, setEditingMilestoneId] = useState(null);
const [newMilestoneMap, setNewMilestoneMap] = useState({});
const [impactsToDeleteMap, setImpactsToDeleteMap] = useState({});
const [addingNewMilestone, setAddingNewMilestone] = useState(false);
const [newMilestoneData, setNewMilestoneData] = useState({
title: "",
@ -40,224 +39,280 @@ export default function MilestoneEditModal({
});
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 */
useEffect(() => {
setMilestones(incomingMils);
}, [incomingMils]);
/*
Inlineedit helpers (trimmed copy of ScenarioContainer logic)
*/
const loadMilestoneImpacts = useCallback(async (m) => {
try {
const impRes = await authFetch(
`/api/premium/milestone-impacts?milestone_id=${m.id}`
);
if (!impRes.ok) throw new Error("impact fetch failed");
const data = await impRes.json();
const impacts = (data.impacts || []).map((imp) => ({
id: imp.id,
impact_type: imp.impact_type || "ONE_TIME",
direction: imp.direction || "subtract",
amount: imp.amount || 0,
start_date: imp.start_date || "",
end_date: imp.end_date || ""
}));
/*
Inline-edit helpers
*/
const [originalImpactIdsMap, setOriginalImpactIdsMap] = useState({}); // snapshot per milestone
setNewMilestoneMap((prev) => ({
...prev,
[m.id]: {
title: m.title || "",
description: m.description || "",
date: m.date || "",
progress: m.progress || 0,
newSalary: m.new_salary || "",
impacts,
isUniversal: m.is_universal ? 1 : 0
}
}));
setEditingMilestoneId(m.id);
setImpactsToDeleteMap((prev) => ({ ...prev, [m.id]: [] }));
} catch (err) {
console.error("loadImpacts", err);
}
}, []);
/* 1⃣ fetch impacts + open editor */
const loadMilestoneImpacts = useCallback(async (m) => {
try {
const res = await authFetch(`/api/premium/milestone-impacts?milestone_id=${m.id}`);
if (!res.ok) throw new Error('impact fetch failed');
const json = await res.json();
const handleEditMilestoneInline = (m) => {
if (editingMilestoneId === m.id) {
setEditingMilestoneId(null);
} else {
loadMilestoneImpacts(m);
}
};
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) => {
setNewMilestoneMap((prev) => {
const itm = prev[milestoneId];
if (!itm) return prev;
const impactsClone = [...itm.impacts, {
impact_type: "ONE_TIME",
direction: "subtract",
amount: 0,
start_date: "",
end_date: ""
}];
return { ...prev, [milestoneId]: { ...itm, impacts: impactsClone } };
});
};
const removeInlineImpact = (mid, idx) => {
setNewMilestoneMap((prev) => {
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];
if (!data) return;
const payload = {
milestone_type: "Financial",
title: data.title,
description: data.description,
date: data.date,
career_profile_id: careerProfileId,
progress: data.progress,
status: data.progress >= 100 ? "completed" : "planned",
new_salary: data.newSalary ? parseFloat(data.newSalary) : null,
is_universal: data.isUniversal || 0
};
try {
const res = await authFetch(`/api/premium/milestones/${m.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const saved = await res.json();
/* impacts */
const toDelete = impactsToDeleteMap[m.id] || [];
for (const delId of toDelete) {
await authFetch(`/api/premium/milestone-impacts/${delId}`, {
method: "DELETE"
});
}
for (const imp of data.impacts) {
const impPayload = {
milestone_id: saved.id,
impact_type: imp.impact_type,
direction: imp.direction,
amount: parseFloat(imp.amount) || 0,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
if (imp.id) {
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impPayload)
});
} else {
await authFetch("/api/premium/milestone-impacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impPayload)
});
}
}
await fetchMilestones();
setEditingMilestoneId(null);
onClose(true);
} catch (err) {
alert("Failed to save milestone");
console.error(err);
}
};
/* brandnew milestone helpers (trimmed) */
const addNewImpactToNewMilestone = () => {
setNewMilestoneData((p) => ({
...p,
impacts: [
...p.impacts,
{
impact_type: "ONE_TIME",
direction: "subtract",
amount: 0,
start_date: "",
end_date: ""
}
]
const impacts = (json.impacts || []).map(imp => ({
id : imp.id,
impact_type : imp.impact_type || 'ONE_TIME',
direction : imp.direction || 'subtract',
amount : imp.amount || 0,
start_date : toSqlDate(imp.start_date) || '',
end_date : toSqlDate(imp.end_date) || ''
}));
/* editable copy for the form */
setNewMilestoneMap(prev => ({
...prev,
[m.id]: {
title : m.title || '',
description : m.description || '',
date : toSqlDate(m.date) || '',
progress : m.progress || 0,
newSalary : m.new_salary || '',
impacts,
isUniversal : m.is_universal ? 1 : 0
}
}));
/* 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) {
console.error('loadImpacts', err);
}
}, []);
/* 2⃣ toggle open / close */
const handleEditMilestoneInline = (milestone) => {
setEditingMilestoneId((curr) =>
curr === milestone.id ? null : milestone.id
);
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 : ''
}
]
}
};
});
};
/* 5⃣ remove one impact row (local only diff happens on save) */
const removeInlineImpact = (mid, idx) => {
setNewMilestoneMap(prev => {
const m = prev[mid];
if (!m) return prev;
const clone = [...m.impacts];
clone.splice(idx, 1);
return { ...prev, [mid]: { ...m, impacts: clone } };
});
};
/* 6⃣ persist the edits PUT milestone, diff impacts */
const saveInlineMilestone = async (m) => {
const data = newMilestoneMap[m.id];
if (!data) return;
/* --- update the milestone header --- */
const payload = {
milestone_type : 'Financial',
title : data.title,
description : data.description,
date : toSqlDate(data.date),
career_profile_id : careerProfileId,
progress : data.progress,
status : data.progress >= 100 ? 'completed' : 'planned',
new_salary : data.newSalary ? parseFloat(data.newSalary) : null,
is_universal : data.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 {
const res = await authFetch("/api/premium/milestone", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const created = Array.isArray(await res.json()) ? (await res.json())[0] : await res.json();
try {
const res = await authFetch(`/api/premium/milestones/${m.id}`, {
method : 'PUT',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const saved = await res.json();
// impacts
for (const imp of newMilestoneData.impacts) {
const impPayload = {
milestone_id: created.id,
impact_type: imp.impact_type,
direction: imp.direction,
amount: parseFloat(imp.amount) || 0,
start_date: imp.start_date || null,
end_date: imp.end_date || null
};
await authFetch("/api/premium/milestone-impacts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(impPayload)
/* --- figure out what changed ---------------------------------- */
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) {
await authFetch(`/api/premium/milestone-impacts/${delId}`, {
method: 'DELETE'
});
}
/* --- creates / updates --- */
for (const imp of data.impacts) {
const impPayload = {
milestone_id : saved.id,
impact_type : imp.impact_type,
direction : imp.direction,
amount : parseFloat(imp.amount) || 0,
start_date : toSqlDate(imp.start_date) || null,
end_date : toSqlDate(imp.end_date) || null
};
if (imp.id) {
await authFetch(`/api/premium/milestone-impacts/${imp.id}`, {
method : 'PUT',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(impPayload)
});
} else {
await authFetch('/api/premium/milestone-impacts', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(impPayload)
});
}
await fetchMilestones();
onClose(true);
} catch (err) {
alert("Failed to save milestone");
}
/* --- refresh + close --- */
await fetchMilestones();
setEditingMilestoneId(null);
} catch (err) {
alert('Failed to save milestone');
console.error(err);
}
};
/* ───────────── misc helpers the JSX still calls ───────────── */
/* A) delete one milestone row altogether */
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: [
...prev.impacts,
{
impact_type : 'ONE_TIME',
direction : 'subtract',
amount : 0,
start_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
};
try {
const res = await authFetch('/api/premium/milestone', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(payload)
});
if (!res.ok) throw new Error(await res.text());
const created =
Array.isArray(await res.json()) ? (await res.json())[0] : await res.json();
/* impacts for the new milestone */
for (const imp of newMilestoneData.impacts) {
const impPayload = {
milestone_id : created.id,
impact_type : imp.impact_type,
direction : imp.direction,
amount : parseFloat(imp.amount) || 0,
start_date : toSqlDate(imp.start_date) || null,
end_date : toSqlDate(imp.end_date) || null
};
await authFetch('/api/premium/milestone-impacts', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify(impPayload)
});
}
await fetchMilestones(); // refresh list
setAddingNewMilestone(false); // collapse the new-mile form
onClose(true);
} catch (err) {
alert('Failed to save milestone');
console.error(err);
}
};
/*
Render
*/
@ -296,6 +351,12 @@ export default function MilestoneEditModal({
<Button onClick={() => handleEditMilestoneInline(m)}>
{hasEditOpen ? "Cancel" : "Edit"}
</Button>
<Button
style={{ marginLeft: "0.5rem", color: "black", backgroundColor: "red" }}
onClick={() => deleteMilestone(m)}
>
Delete
</Button>
</div>
<p>{m.description}</p>
<p>
@ -343,7 +404,7 @@ export default function MilestoneEditModal({
/>
{/* impacts */}
<div style={{ border: "1px solid #ccc", padding: "0.5rem", marginBottom: "0.5rem" }}>
<h6>Impacts</h6>
<h6>Financial Impacts</h6>
{data.impacts?.map((imp, idx) => (
<div key={idx} style={{ border: "1px solid #bbb", margin: "0.5rem 0", padding: "0.3rem" }}>
<label>Type:</label>
@ -351,6 +412,7 @@ export default function MilestoneEditModal({
value={imp.impact_type}
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="MONTHLY">Monthly</option>
</select>
@ -389,7 +451,7 @@ export default function MilestoneEditModal({
</Button>
</div>
))}
<Button onClick={() => addInlineImpact(m.id)}>+ Impact</Button>
<Button onClick={() => addInlineImpact(m.id)}>+ Financial Impact</Button>
</div>
<Button onClick={() => saveInlineMilestone(m)}>Save</Button>
</div>
@ -441,6 +503,7 @@ export default function MilestoneEditModal({
});
}}
>
<option value="salary">Salary (annual)</option>
<option value="ONE_TIME">One-Time</option>
<option value="MONTHLY">Monthly</option>
</select>
@ -516,7 +579,7 @@ export default function MilestoneEditModal({
</Button>
</div>
))}
<Button onClick={addNewImpactToNewMilestone}>+ Impact</Button>
<Button onClick={addNewImpactToNewMilestone}>+ Financial Impact</Button>
</div>
<Button onClick={saveNewMilestone}>Add Milestone</Button>
</div>

View File

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

View File

@ -370,41 +370,48 @@ for (let monthIndex = 0; monthIndex < maxMonths; monthIndex++) {
baseMonthlyIncome = (currentSalary / 12) + (partTimeIncome / 12);
}
/************************************************
* 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) => {
/* --- safety / coercion ------------------------------------------------ */
const amount = Number(rawImpact.amount) || 0; // ← always a number
const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // 'ONE_TIME' | 'MONTHLY'
const direction = (rawImpact.direction || 'subtract').toLowerCase(); // 'add' | 'subtract'
/* ---------- 1. Normalise ---------- */
const amount = Number(rawImpact.amount) || 0;
const type = (rawImpact.impact_type || 'MONTHLY').toUpperCase(); // SALARY / SALARY_ANNUAL / MONTHLY / ONE_TIME
const direction = (rawImpact.direction || 'subtract').toLowerCase(); // add / subtract
/* --- date math -------------------------------------------------------- */
const startDateClamped = moment(rawImpact.start_date).startOf('month');
let startOffset = startDateClamped.diff(scenarioStartClamped, 'months');
if (startOffset < 0) startOffset = 0;
/* ---------- 2. Work out timing ---------- */
const startDate = moment(rawImpact.start_date).startOf('month');
const endDate = rawImpact.end_date ? moment(rawImpact.end_date).startOf('month') : null;
let endOffset = Infinity;
if (rawImpact.end_date && rawImpact.end_date.trim() !== '') {
const endDateClamped = moment(rawImpact.end_date).startOf('month');
endOffset = endDateClamped.diff(scenarioStartClamped, 'months');
if (endOffset < 0) endOffset = 0;
}
const startOffset = Math.max(0, startDate.diff(scenarioStartClamped, 'months'));
const endOffset = endDate ? Math.max(0, endDate.diff(scenarioStartClamped, 'months')) : Infinity;
/* --- apply impact ----------------------------------------------------- */
const applyAmount = (dir) =>
dir === 'add' ? (baseMonthlyIncome += amount) : (extraImpactsThisMonth += amount);
const isActiveThisMonth =
(type === 'ONE_TIME' && monthIndex === startOffset) ||
(type !== 'ONE_TIME' && monthIndex >= startOffset && monthIndex <= endOffset);
if (type === 'ONE_TIME') {
if (monthIndex === startOffset) applyAmount(direction);
if (!isActiveThisMonth) return; // skip to next impact
/* ---------- 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 {
// MONTHLY (or anything else) apply for the whole span
if (monthIndex >= startOffset && monthIndex <= endOffset) applyAmount(direction);
// MONTHLY or ONE_TIME expenses / windfalls
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