From 7d27dc160c93922c25ceb46d5b821e86a07b63fc Mon Sep 17 00:00:00 2001 From: Josh Date: Tue, 19 Aug 2025 17:01:31 +0000 Subject: [PATCH] final fixes before rotating secrets: college profile ciphertext, selectedCareer/premiumonboardingstate carry into CareerRoadmap, etc. --- .build.hash | 2 +- backend/server3.js | 15 ++- src/components/CareerCoach.js | 55 ++++---- src/components/CareerProfileForm.js | 5 +- src/components/CareerRoadmap.js | 149 ++++++++++++++++++---- src/components/EducationalProgramsPage.js | 19 ++- 6 files changed, 185 insertions(+), 60 deletions(-) diff --git a/.build.hash b/.build.hash index 8d73de0..04756d1 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -fcb1ff42e88c57ae313a74da813f6a3cdb19904f-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b +2aa2de355546f6401f80a07e00066bd83f37fdc6-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/backend/server3.js b/backend/server3.js index 5e8cb15..4ec79f5 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -17,6 +17,7 @@ import { v4 as uuidv4 } from 'uuid'; import pkg from 'pdfjs-dist'; import pool from './config/mysqlPool.js'; import { v4 as uuid } from 'uuid'; +import { decrypt } from './shared/crypto/encryption.js' import OpenAI from 'openai'; import Fuse from 'fuse.js'; @@ -3029,8 +3030,18 @@ app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req, WHERE cp.user_id = ? ORDER BY cp.created_at DESC `; - const [rows] = await pool.query(sql,[req.id]); - res.json({ collegeProfiles: rows }); + const [rows] = await pool.query(sql, [req.id]); + const decrypted = rows.map(r => { + const row = { ...r }; + for (const k of ['career_title', 'selected_school', 'selected_program']) { + const v = row[k]; + if (typeof v === 'string' && v.startsWith('gcm:')) { + try { row[k] = decrypt(v); } catch {} // best-effort + } + } + return row; + }); + res.json({ collegeProfiles: decrypted }); }); /* ------------------------------------------------------------------ diff --git a/src/components/CareerCoach.js b/src/components/CareerCoach.js index c42a6d8..37d9dd8 100644 --- a/src/components/CareerCoach.js +++ b/src/components/CareerCoach.js @@ -126,41 +126,42 @@ 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]); - useEffect(() => { (async () => { if (!careerProfileId) return; - // try to reuse the newest coach thread; create one named after the scenario - const r = await authFetch('/api/premium/coach/chat/threads'); + + // list threads for this profile + const r = await authFetch( + `/api/premium/coach/chat/threads?careerProfileId=${encodeURIComponent(careerProfileId)}` + ); + + if (!(r.ok && (r.headers.get('content-type') || '').includes('application/json'))) { + setThreadId(null); // coach offline; no network errors on mount + return; + } + const { threads = [] } = await r.json(); const existing = threads.find(Boolean); - let id = existing?.id; - if (!id) { - const r2 = await authFetch('/api/premium/coach/chat/threads', { - method:'POST', - headers:{ 'Content-Type':'application/json' }, - body: JSON.stringify({ title: (scenarioRow?.scenario_title || scenarioRow?.career_name || 'Coach chat') }) - }); - ({ id } = await r2.json()); + if (!existing?.id) { + setThreadId(null); // no thread yet; lazy-create on first send + return; } + + const id = existing.id; setThreadId(id); // preload history - const r3 = await authFetch(`/api/premium/coach/chat/threads/${id}`); - const { messages: msgs = [] } = await r3.json(); - setMessages(msgs); + const r3 = await authFetch( + `/api/premium/coach/chat/threads/${id}?careerProfileId=${encodeURIComponent(careerProfileId)}` + ); + if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) { + const { messages: msgs = [] } = await r3.json(); + setMessages(msgs); + } })(); }, [careerProfileId]); + /* -------------- intro ---------------- */ useEffect(() => { if (!scenarioRow) return; @@ -234,14 +235,18 @@ I'm here to support you with personalized coaching. What would you like to focus async function callAi(updatedHistory, opts = {}) { setLoading(true); try { + if (!threadId) throw new Error('thread not ready'); const context = { userProfile, financialProfile, scenarioRow, collegeProfile }; const r = await authFetch(`/api/premium/coach/chat/threads/${threadId}/messages`, { method:'POST', headers:{ 'Content-Type':'application/json' }, body: JSON.stringify({ content: updatedHistory.at(-1)?.content || '', context }) }); - const data = await r.json(); - const reply = (data?.reply || '').trim() || 'Sorry, something went wrong.'; + let reply = 'Sorry, something went wrong.'; + if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) { + const data = await r.json(); + reply = (data?.reply || '').trim() || reply; +} setMessages(prev => [...prev, { role:'assistant', content: reply }]); } catch (e) { console.error(e); diff --git a/src/components/CareerProfileForm.js b/src/components/CareerProfileForm.js index 842669a..fdbdfe6 100644 --- a/src/components/CareerProfileForm.js +++ b/src/components/CareerProfileForm.js @@ -84,7 +84,10 @@ export default function CareerProfileForm() { }) }); if (!res.ok) throw new Error(await res.text()); - nav(-1); + const data = await res.json(); // { career_profile_id: '...' } + const activeId = data.career_profile_id || id; // handle edit vs new + localStorage.setItem('lastSelectedCareerProfileId', activeId); + nav(`/career-roadmap/${activeId}`, { replace: true }); // guarantees Roadmap selects this one } catch (err) { console.error(err); alert(err.message); diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index 72017d1..cc024d1 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -395,7 +395,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) { const chat = useContext(ChatCtx) || {}; const setChatSnapshot = chat?.setChatSnapshot; - const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null; + const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null; // unchanged; {} is truthy const reloadScenarioAndCollege = useCallback(async () => { if (!careerProfileId) return; @@ -409,10 +409,24 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) { setScenarioRow(row); } - const c = await authFetch( - `/api/premium/college-profile?careerProfileId=${careerProfileId}` - ); - if (c.ok) setCollegeProfile(await c.json()); + const c = await authFetch(`/api/premium/college-profile?careerProfileId=${encodeURIComponent(careerProfileId)}`); + if (c.status === 404) { + setCollegeProfile({}); // no profile yet → use defaults + return; + } + if (c.ok) { + const ct = c.headers.get('content-type') || ''; + if (ct.includes('application/json')) { + setCollegeProfile(await c.json()); + } else { + // defensive: upstream returned HTML (e.g., 502 body with wrong status) + setCollegeProfile({}); + } + return; + } + // non-OK (e.g., 502) + console.error('college-profile fetch failed', c.status); + setCollegeProfile({}); }, [careerProfileId]); const milestoneGroups = useMemo(() => { @@ -514,15 +528,16 @@ useEffect(() => { useEffect(() => { (async () => { const up = await authFetch('/api/user-profile'); - if (up.ok) setUserProfile(await up.json()); - + if (up.ok && (up.headers.get('content-type')||'').includes('application/json')) { + setUserProfile(await up.json()); + } const fp = await authFetch('/api/premium/financial-profile'); if (fp.status === 404) { // user skipped onboarding – treat as empty object setFinancialProfile({}); - } else if (fp.ok) { - setFinancialProfile(await fp.json()); - } + } else if (fp.ok && (fp.headers.get('content-type')||'').includes('application/json')) { + setFinancialProfile(await fp.json()); +} })(); }, []); @@ -689,7 +704,92 @@ useEffect(() => { let cancelled = false; (async function init () { - /* 1 ▸ get every row the user owns */ + // ───────────────────────────────────────────────────────────── + // 0) Prefer a real profile. Use ephemeral ONLY if none matches. + // Precedence when arriving with premiumOnboardingState: + // a) SOC match + // b) title match + // c) school match (via college_profile/all) + // ───────────────────────────────────────────────────────────── + if (!careerId) { + const pos = location.state?.premiumOnboardingState; + const sc = pos?.selectedCareer; + if (sc) { + const chosenSoc = sc.code || sc.soc_code || sc.socCode || ''; + const chosenName = (sc.title || sc.career_name || '').trim(); + + // 1) fetch all user profiles + let all = []; + try { + all = await getAllCareerProfiles(); // uses authFetch + } catch { /* ignore; backend will log */ } + + const bySoc = chosenSoc + ? all.find(p => p.soc_code === chosenSoc) + : null; + + const norm = (s='') => s.toLowerCase().replace(/\s+/g,' ').trim(); + const byTitle = !bySoc && chosenName + ? all.find(p => norm(p.career_name||p.scenario_title||'') === norm(chosenName)) + : null; + + let bySchool = null; + if (!bySoc && !byTitle && pos.selectedSchool) { + const schoolName = norm(pos.selectedSchool?.INSTNM || ''); + try { + const r = await authFetch('/api/premium/college-profile/all'); + if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) { + const { collegeProfiles = [] } = await r.json(); + const ids = new Set( + collegeProfiles + .filter(cp => norm(cp.selected_school || '') === schoolName) + .map(cp => cp.career_profile_id) + ); + // pick newest start_date among candidates + const sorted = [...all].sort((a,b) => (a.start_date > b.start_date ? -1 : 1)); + bySchool = sorted.find(p => ids.has(p.id)) || null; + } + } catch { /* ignore */ } + } + + const match = bySoc || byTitle || bySchool; + if (match && !cancelled) { + // ✅ Normal mode: select existing profile + setCareerProfileId(match.id); + setSelectedCareer(match); + localStorage.setItem('lastSelectedCareerProfileId', match.id); + window.history.replaceState({}, '', location.pathname); // clear bridge + return; // let the rest of the effects run as usual + } + + // ⚠️ No matching profile → ephemeral (financial profile is still used) + if (!cancelled) { + setCareerProfileId(null); + setSelectedCareer(sc); + setScenarioRow({ + id: 'temp', + scenario_title: chosenName, + career_name : chosenName, + status : 'planned', + start_date : new Date().toISOString().slice(0,10), + college_enrollment_status: '' + }); + const sch = pos.selectedSchool; + setCollegeProfile(sch ? { + selected_school : sch?.INSTNM || '', + selected_program : sch?.CIPDESC || '', + tuition : Number(sch?.['In_state cost'] ?? sch?.['Out_state cost'] ?? 0) || 0 + } : {}); + if (chosenSoc) { + setFullSocCode(chosenSoc); + setStrippedSocCode(chosenSoc.split('.')[0]); + } + window.history.replaceState({}, '', location.pathname); // clear bridge + return; + } + } + } + const r = await authFetch('/api/premium/career-profile/all'); if (!r?.ok || cancelled) return; const { careerProfiles=[] } = await r.json(); @@ -943,10 +1043,10 @@ useEffect(() => { { signal: ctrl.signal } ); - if (res.ok) { - setEconomicProjections(await res.json()); - setEconLoading(false); - } else { + if (res.ok && (res.headers.get('content-type')||'').includes('application/json')) { + setEconomicProjections(await res.json()); + setEconLoading(false); + } else { console.error('[Econ fetch]', res.status); setEconLoading(false); } @@ -1101,7 +1201,7 @@ if (allMilestones.length) { annualFinancialAid: collegeData.annualFinancialAid, calculatedTuition: collegeData.calculatedTuition, extraPayment: collegeData.extraPayment, - enrollmentDate: collegeProfile.enrollment_Date || null, + enrollmentDate: collegeProfile.enrollment_date || null, inCollege: collegeData.inCollege, gradDate: collegeData.gradDate, programType: collegeData.programType, @@ -1138,7 +1238,7 @@ if (allMilestones.length) { } useEffect(() => { - if (!financialProfile || !scenarioRow || !collegeProfile) return; + if (!financialProfile || !scenarioRow || collegeProfile === null) return; fetchMilestones(); }, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]); @@ -1284,16 +1384,11 @@ const currentIdRef = useRef(null); const fetchMilestones = useCallback(async () => { if (!careerProfileId) return; - const [profRes, uniRes] = await Promise.all([ - authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`), - authFetch(`/api/premium/milestones?careerProfileId=universal`) - ]); - if (!profRes.ok || !uniRes.ok) return; - - const [{ milestones: profMs }, { milestones: uniMs }] = - await Promise.all([profRes.json(), uniRes.json()]); - - const merged = [...profMs, ...uniMs]; +const profRes = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`); + if (!profRes.ok) return; + const ct = profRes.headers.get('content-type') || ''; + const { milestones: profMs = [] } = ct.includes('application/json') ? await profRes.json() : { milestones: [] }; + const merged = profMs; setScenarioMilestones(merged); if (financialProfile && scenarioRow && collegeProfile) { buildProjection(merged); diff --git a/src/components/EducationalProgramsPage.js b/src/components/EducationalProgramsPage.js index a90780e..a91b96a 100644 --- a/src/components/EducationalProgramsPage.js +++ b/src/components/EducationalProgramsPage.js @@ -172,10 +172,21 @@ function normalizeCipList(arr) { const proceed = window.confirm( 'You’re about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' ); - if (proceed) { - navigate('/career-roadmap', { state: { selectedSchool: school } }); - } - }; + if (!proceed) return; + // normalize selectedCareer and carry it forward + const sel = selectedCareer + ? { ...selectedCareer, code: selectedCareer.code || selectedCareer.soc_code || selectedCareer.socCode } + : null; + + navigate('/career-roadmap', { + state: { + premiumOnboardingState: { + selectedCareer: sel, // SOC-bearing career object + selectedSchool: school // school card just chosen + } + } + }); +}; function getSearchLinks(ksaName, careerTitle) { const combinedQuery = `${careerTitle} ${ksaName}`.trim();