diff --git a/backend/server3.js b/backend/server3.js index 14574eb..72b64ac 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -8,7 +8,7 @@ import dotenv from 'dotenv'; import path from 'path'; import fs from 'fs/promises'; import multer from 'multer'; -import authFetch from '../src/utils/authFetch.js'; // Adjust path as needed +import fetch from "node-fetch"; import mammoth from 'mammoth'; import { fileURLToPath } from 'url'; import jwt from 'jsonwebtoken'; @@ -27,10 +27,23 @@ const env = process.env.NODE_ENV?.trim() || 'development'; const envPath = path.resolve(rootPath, `.env.${env}`); dotenv.config({ path: envPath }); // Load .env file +const apiBase = process.env.APTIVA_API_BASE || "http://localhost:5002/api"; + const app = express(); const PORT = process.env.PREMIUM_PORT || 5002; const { getDocument } = pkg; +function internalFetch(req, url, opts = {}) { + return fetch(url, { + ...opts, + headers: { + "Content-Type": "application/json", + Authorization: req.headers?.authorization || "", // tolerate undefined + ...(opts.headers || {}) + } + }); +} + // 1) Create a MySQL pool using your environment variables const pool = mysql.createPool({ @@ -493,6 +506,18 @@ app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => { chatHistory = [] } = req.body; + let existingTitles = []; + try { + const [rows] = await pool.query( + `SELECT title, DATE_FORMAT(date,'%Y-%m-%d') AS d + FROM milestones + WHERE user_id = ? AND career_profile_id = ?`, + [req.id, scenarioRow.id] + ); + existingTitles = rows.map(r => `${r.title.trim()}|${r.d}`); + } catch (e) { + console.error("Could not fetch existing milestones =>", e); + } // ------------------------------------------------ // 1. Helper Functions // ------------------------------------------------ @@ -833,11 +858,52 @@ ${combinedStatusSituation} ${summaryText} `.trim(); + const systemPromptMilestoneFormat = ` +WHEN the user wants a plan with milestones, tasks, and financial impacts: +RESPOND ONLY with valid JSON in this shape: + +{ + "milestones": [ + { + "title": "string", + "date": "YYYY-MM-DD", + "description": "1 or 2 sentences", + "impacts": [ + { + "impact_type": "cost" or "salary" or ..., + "direction": "add" or "subtract", + "amount": 100.00, + "start_date": "YYYY-MM-DD" (optional), + "end_date": "YYYY-MM-DD" (optional) + } + ], + "tasks": [ + { + "title": "string", + "description": "string", + "due_date": "YYYY-MM-DD" + } + ] + }, + ... + ] +} + NO extra text or disclaimers if returning a plan. Only that JSON. +Otherwise, answer normally. +`.trim(); + +const avoidBlock = existingTitles.length + ? "\nAVOID repeating any of these title|date combinations:\n" + + existingTitles.map(t => `- ${t}`).join("\n") + : ""; + // Build up the final messages array const messagesToSend = [ { role: "system", content: systemPromptIntro }, { role: "system", content: systemPromptStatusSituation }, { role: "system", content: systemPromptDetailedContext }, + { role: "system", content: systemPromptMilestoneFormat }, + { role: "system", content: systemPromptMilestoneFormat + avoidBlock }, // <-- merged ...chatHistory // includes user and assistant messages so far ]; @@ -872,6 +938,11 @@ ${summaryText} // The AI plan is expected to have: planObj.milestones[] if (planObj && Array.isArray(planObj.milestones)) { for (const milestone of planObj.milestones) { + const dupKey = `${(milestone.title || "").trim()}|${milestone.date}`; + if (existingTitles.includes(dupKey)) { + console.log("Skipping duplicate milestone:", dupKey); + continue; // do NOT insert + } // Create the milestone const milestoneBody = { title: milestone.title, @@ -884,65 +955,90 @@ ${summaryText} }; // Call your existing milestone endpoint - const msRes = await authFetch("/api/premium/milestone", { + const msRes = await internalFetch(req, `${apiBase}/premium/milestone`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(milestoneBody) }); const createdMs = await msRes.json(); - // Figure out the new milestone ID - let newMilestoneId = null; - if (Array.isArray(createdMs) && createdMs[0]) { - newMilestoneId = createdMs[0].id; - } else if (createdMs.id) { - newMilestoneId = createdMs.id; - } - - // If we have a milestoneId, create tasks & impacts - if (newMilestoneId) { - // tasks - if (Array.isArray(milestone.tasks)) { - for (const t of milestone.tasks) { - const taskBody = { - milestone_id: newMilestoneId, - title: t.title, - description: t.description || "", - due_date: t.due_date || null - }; - await authFetch("/api/premium/tasks", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(taskBody) - }); - } + // Figure out the new milestone ID + let newMilestoneId = null; + if (Array.isArray(createdMs) && createdMs[0]) { + newMilestoneId = createdMs[0].id; + } else if (createdMs.id) { + newMilestoneId = createdMs.id; } - // impacts - if (Array.isArray(milestone.impacts)) { - for (const imp of milestone.impacts) { - const impactBody = { - milestone_id: newMilestoneId, - impact_type: imp.impact_type, - direction: imp.direction, - amount: imp.amount, - 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(impactBody) - }); + // If we have a milestoneId, create tasks & impacts + if (newMilestoneId) { + /* ---------- TASKS ---------- */ + if (Array.isArray(milestone.tasks)) { + for (const t of milestone.tasks) { + // tolerate plain-string tasks → convert to minimal object + const taskObj = + typeof t === "string" + ? { title: t, description: "", due_date: null } + : t; + + if (!taskObj.title) continue; // skip invalid + + const taskBody = { + milestone_id: newMilestoneId, + title: taskObj.title, + description: taskObj.description || "", + due_date: taskObj.due_date || null + }; + + await internalFetch(req, `${apiBase}/premium/tasks`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(taskBody) + }); + } } + + /* ---------- IMPACTS ---------- */ + if (Array.isArray(milestone.impacts)) { + for (const imp of milestone.impacts) { + // tolerate plain-string impacts + const impObj = + typeof imp === "string" + ? { + impact_type: "note", + direction: "add", + amount: 0, + start_date: null, + end_date: null + } + : imp; + + if (!impObj.impact_type) continue; // skip invalid + + const impactBody = { + milestone_id: newMilestoneId, + impact_type: impObj.impact_type, + direction: impObj.direction, + amount: impObj.amount, + start_date: impObj.start_date || null, + end_date: impObj.end_date || null + }; + + await internalFetch(req, `${apiBase}/premium/milestone-impacts`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(impactBody) + }); + } + } + + /* ---------- Track the new milestone ---------- */ + createdMilestonesData.push({ + milestoneId: newMilestoneId, + title: milestone.title + }); } - // Keep track of the newly created milestone - createdMilestonesData.push({ - milestoneId: newMilestoneId, - title: milestone.title - }); - } } // If we successfully created at least 1 milestone, diff --git a/src/components/CareerCoach.js b/src/components/CareerCoach.js index 72ff6b0..6ff1142 100644 --- a/src/components/CareerCoach.js +++ b/src/components/CareerCoach.js @@ -1,6 +1,59 @@ import React, { useState, useEffect, useRef } from "react"; import authFetch from "../utils/authFetch.js"; +const isoToday = new Date().toISOString().slice(0,10); // top-level helper + +/* ---------------------------------------------- + Hidden prompts for the quick-action buttons + ---------------------------------------------- */ +const QUICK_PROMPTS = { + networking: ` + +Return **ONLY** valid JSON: +TODAY = ${isoToday} +**Every milestone.date must be >= TODAY** + +{ + "milestones": [ + { + "title": "<= 5 words", + "date": "YYYY-MM-DD", + "description": "1–2 sentences", + "impacts": [ + { + "impact_type": "cost" | "salary" | "none", + "direction": "add" | "subtract", + "amount": 0, + "start_date": null, + "end_date": null + } + ], + "tasks": [ + { + "title": "string", + "description": "string", + "due_date": "YYYY-MM-DD" + } + ] + } + ] +} + +NO extra commentary. The JSON will be stored as milestones.`.trim(), + + jobSearch: ` +Return **ONLY** valid JSON in the **same structure** (title, date, description, impacts[{}], tasks[{}]) for a Job-Search roadmap. TODAY = ${isoToday} +**Every milestone.date must be >= TODAY** NO extra text.`.trim(), + + interview: ` +You are an expert interview coach. +Ask one behavioural or technical question, wait for the user's reply, +score 1-5, give constructive feedback, then ask the next question. +Stop after 5 questions or if the user types "quit interview". +Do NOT output milestones JSON.`.trim() +}; + + export default function CareerCoach({ userProfile, financialProfile, @@ -9,39 +62,30 @@ export default function CareerCoach({ onMilestonesCreated, onAiRiskFetched }) { + /* -------------- state ---------------- */ const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [loading, setLoading] = useState(false); - const chatContainerRef = useRef(null); - const [hasSentMessage, setHasSentMessage] = useState(false); - const [prevRiskLevel, setPrevRiskLevel] = useState(null); - - // NEW: optional local state to hold the AI risk data if you want to show it somewhere const [aiRisk, setAiRisk] = useState(null); + const chatRef = useRef(null); + /* -------------- scroll --------------- */ useEffect(() => { - if (chatContainerRef.current) { - chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight; - } - }, [messages, hasSentMessage]); + if (chatRef.current) chatRef.current.scrollTop = chatRef.current.scrollHeight; + }, [messages]); + /* -------------- intro ---------------- */ useEffect(() => { - if (!scenarioRow?.riskLevel) return; - - // If it hasn't changed, skip - if (scenarioRow.riskLevel === prevRiskLevel) return; - - setPrevRiskLevel(scenarioRow.riskLevel); - - // Now generate the intro once - const introMessage = generatePersonalizedIntro(); - setMessages([introMessage]); -}, [scenarioRow]); + if (!scenarioRow) return; + setMessages([generatePersonalizedIntro()]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scenarioRow?.id]); + /* ---------- helpers you already had ---------- */ function buildStatusSituationMessage(status, situation, careerName) { + /* (unchanged body) */ const sStatus = (status || "").toLowerCase(); const sSituation = (situation || "").toLowerCase(); - let nowPart = ""; switch (sStatus) { case "planned": @@ -55,9 +99,7 @@ export default function CareerCoach({ break; default: nowPart = `I don’t have a clear picture of your involvement with ${careerName}, but I’m here to help.`; - break; } - let nextPart = ""; switch (sSituation) { case "planning": @@ -74,184 +116,152 @@ export default function CareerCoach({ break; default: nextPart = `I'm not entirely sure of your next direction, but we’ll keep your background in mind.`; - break; } - - const combinedDescription = `${nowPart} ${nextPart}`.trim(); - const friendlyNote = ` Feel free to use AptivaAI however it best suits you—there’s no "wrong" answer. AptivaAI asks for some of your current situation so we can provide the best guidance on what you should do next to reach your goals. It's really about where you want to go from here (that's all you can control anyway). -We can refine details anytime or just jump straight to what you're most interested in exploring now! - `.trim(); - - return `${combinedDescription}\n${friendlyNote}`; +We can refine details anytime or just jump straight to what you're most interested in exploring now!`; + return `${nowPart} ${nextPart}\n${friendlyNote}`; } - const generatePersonalizedIntro = () => { + function generatePersonalizedIntro() { + /* (unchanged body) */ const careerName = scenarioRow?.career_name || "this career"; const goalsText = scenarioRow?.career_goals?.trim() || null; const riskLevel = scenarioRow?.riskLevel; const riskReasoning = scenarioRow?.riskReasoning; - const userSituation = userProfile?.career_situation?.toLowerCase(); - const userStatus = scenarioRow?.status?.toLowerCase(); + const userStatus = scenarioRow?.status?.toLowerCase(); + const combined = buildStatusSituationMessage(userStatus, userSituation, careerName); - const combinedMessage = buildStatusSituationMessage( - userStatus, - userSituation, - careerName - ); + const intro = ` +Hi! ${combined}
+${goalsText ? `Your goals include:
${goalsText.split(/^\d+\.\s+/gm).filter(Boolean).map(g => `• ${g.trim()}`).join("
")}
` : ""} +${riskLevel ? `Note: This role has a ${riskLevel} automation risk over the next 10 years. ${riskReasoning}
` : ""} +I'm here to support you with personalized coaching. What would you like to focus on today?`; + return { role: "assistant", content: intro }; + } - const hasInterestAnswers = Boolean(userProfile?.interest_inventory_answers?.trim()); - -const interestInventoryMessage = hasInterestAnswers - ? `Since you've completed the Interest Inventory, I can offer more targeted suggestions based on your responses.` - : `If you complete the Interest Inventory, I’ll be able to offer more targeted suggestions based on your interests.`; - - - const riskMessage = - riskLevel && riskReasoning - ? `Note: This role has a ${riskLevel} automation risk over the next 10 years. ${riskReasoning}` - : ""; - - const goalsMessage = goalsText - ? `Your goals include:
${goalsText - .split(/^\d+\.\s+/gm) - .filter(Boolean) - .map((goal) => `• ${goal.trim()}`) - .join("
")}` - : null; - - const missingProfileFields = []; - if (!scenarioRow?.career_name) missingProfileFields.push("career choice"); - if (!goalsText) missingProfileFields.push("career goals"); - if (!userSituation) missingProfileFields.push("career phase"); - - let advisoryMessage = ""; - if (missingProfileFields.length > 0) { - advisoryMessage = `If you provide ${ - missingProfileFields.length > 1 - ? "a few more details" - : "this information" - }, I’ll be able to offer more tailored and precise advice.`; - } - - return { - role: "assistant", - content: ` - Hi! ${combinedMessage}
- ${goalsMessage ? goalsMessage + "
" : ""} - ${interestInventoryMessage}
- ${riskMessage}
- ${advisoryMessage}
- I'm here to support you with personalized coaching. What would you like to focus on today? - `, - }; - }; - - const handleSendMessage = async () => { - if (!input.trim() || loading) return; - - const userMessage = { role: "user", content: input.trim() }; - const updatedMessages = [...messages, userMessage]; - - setMessages(updatedMessages); - setInput(""); + /* ------------ shared AI caller ------------- */ + async function callAi(updatedHistory) { setLoading(true); - try { const payload = { userProfile, financialProfile, scenarioRow, collegeProfile, - chatHistory: updatedMessages, + chatHistory: updatedHistory }; - const res = await authFetch("/api/premium/ai/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), + body: JSON.stringify(payload) }); + const { reply, aiRisk: riskData, createdMilestones = [] } = await res.json(); - if (!res.ok) throw new Error("AI request failed"); + // If GPT accidentally returned raw JSON, hide it from user + const isJson = reply.trim().startsWith("{") || reply.trim().startsWith("["); + const friendlyReply = isJson + ? "✅ Got it! I added new milestones to your plan. Check your Milestones tab." + : reply; - // Here we destructure out aiRisk from the JSON - // so we can store it or display it in the frontend - const { reply, aiRisk: riskDataFromServer } = await res.json(); + setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]); - setMessages((prev) => [...prev, { role: "assistant", content: reply }]); - - // OPTIONAL: store or use the AI risk data - if (riskDataFromServer && onAiRiskFetched) { - onAiRiskFetched(riskDataFromServer); - } - - if ( - reply.includes("created those milestones") || - reply.includes("milestones for you") - ) { - onMilestonesCreated?.(); - } + if (riskData && onAiRiskFetched) onAiRiskFetched(riskData); + if (createdMilestones.length && onMilestonesCreated) + onMilestonesCreated(createdMilestones.length); } catch (err) { - console.error("CareerCoach error:", err); - setMessages((prev) => [ - ...prev, - { - role: "assistant", - content: "Sorry, something went wrong. Please try again.", - }, - ]); + console.error(err); + setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]); } finally { setLoading(false); } - }; + } - const handleSubmit = (e) => { + /* ------------ normal send ------------- */ + function handleSubmit(e) { e.preventDefault(); - handleSendMessage(); - setHasSentMessage(true); - }; + if (!input.trim() || loading) return; + const userMsg = { role: "user", content: input.trim() }; + const newHistory = [...messages, userMsg]; + setMessages(newHistory); + setInput(""); + callAi(newHistory); + } + /* ------------ quick-action buttons ------------- */ + function triggerQuickAction(type) { + if (loading) return; + + // 1. Add a visible note for user *without* showing the raw system prompt + const note = { + role: "assistant", + content: + type === "interview" + ? "Starting mock interview! (answer each question and I’ll give feedback)" + : `Sure! Let me create a ${type === "networking" ? "Networking" : "Job-Search"} roadmap for you…` + }; + const hiddenSystem = { role: "system", content: QUICK_PROMPTS[type] }; + + const updatedHistory = [...messages, note, hiddenSystem]; + setMessages([...messages, note]); // show only the friendly note + callAi(updatedHistory); + } + + /* ------------ render ------------- */ return (

Career Coach

+ {/* Quick-action bar */} +
+ + + +
+ + {/* Chat area */}
- {messages.map((msg, i) => ( + {messages.map((m, i) => (
-
"), - }} - /> -
+ dangerouslySetInnerHTML={{ __html: m.content.replace(/\n/g, "
") }} + /> ))} - {loading && ( -
Coach is typing...
- )} + {loading &&
Coach is typing…
}
- {/* Optionally display AI risk info here if you'd like */} - {aiRisk && aiRisk.riskLevel && ( + {/* AI risk banner */} + {aiRisk?.riskLevel && (
Automation Risk: {aiRisk.riskLevel}
@@ -259,23 +269,23 @@ const interestInventoryMessage = hasInterestAnswers
)} -
+ {/* Input */} + setInput(e.target.value)} disabled={loading} + placeholder="Ask your Career Coach…" + className="flex-grow border rounded py-2 px-3" />