final fixes before rotating secrets: college profile ciphertext, selectedCareer/premiumonboardingstate carry into CareerRoadmap, etc.

This commit is contained in:
Josh 2025-08-19 17:01:31 +00:00
parent 5838f782e7
commit 7d27dc160c
6 changed files with 185 additions and 60 deletions

View File

@ -1 +1 @@
fcb1ff42e88c57ae313a74da813f6a3cdb19904f-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b 2aa2de355546f6401f80a07e00066bd83f37fdc6-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -17,6 +17,7 @@ import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist'; import pkg from 'pdfjs-dist';
import pool from './config/mysqlPool.js'; import pool from './config/mysqlPool.js';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
import { decrypt } from './shared/crypto/encryption.js'
import OpenAI from 'openai'; import OpenAI from 'openai';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
@ -3029,8 +3030,18 @@ app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req,
WHERE cp.user_id = ? WHERE cp.user_id = ?
ORDER BY cp.created_at DESC ORDER BY cp.created_at DESC
`; `;
const [rows] = await pool.query(sql,[req.id]); const [rows] = await pool.query(sql, [req.id]);
res.json({ collegeProfiles: rows }); 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 });
}); });
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------

View File

@ -126,41 +126,42 @@ 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]);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (!careerProfileId) return; 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 { threads = [] } = await r.json();
const existing = threads.find(Boolean); const existing = threads.find(Boolean);
let id = existing?.id; if (!existing?.id) {
if (!id) { setThreadId(null); // no thread yet; lazy-create on first send
const r2 = await authFetch('/api/premium/coach/chat/threads', { return;
method:'POST',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ title: (scenarioRow?.scenario_title || scenarioRow?.career_name || 'Coach chat') })
});
({ id } = await r2.json());
} }
const id = existing.id;
setThreadId(id); setThreadId(id);
// preload history // preload history
const r3 = await authFetch(`/api/premium/coach/chat/threads/${id}`); const r3 = await authFetch(
const { messages: msgs = [] } = await r3.json(); `/api/premium/coach/chat/threads/${id}?careerProfileId=${encodeURIComponent(careerProfileId)}`
setMessages(msgs); );
if (r3.ok && (r3.headers.get('content-type') || '').includes('application/json')) {
const { messages: msgs = [] } = await r3.json();
setMessages(msgs);
}
})(); })();
}, [careerProfileId]); }, [careerProfileId]);
/* -------------- intro ---------------- */ /* -------------- intro ---------------- */
useEffect(() => { useEffect(() => {
if (!scenarioRow) return; 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 = {}) { async function callAi(updatedHistory, opts = {}) {
setLoading(true); setLoading(true);
try { try {
if (!threadId) throw new Error('thread not ready');
const context = { userProfile, financialProfile, scenarioRow, collegeProfile }; const context = { userProfile, financialProfile, scenarioRow, collegeProfile };
const r = await authFetch(`/api/premium/coach/chat/threads/${threadId}/messages`, { const r = await authFetch(`/api/premium/coach/chat/threads/${threadId}/messages`, {
method:'POST', method:'POST',
headers:{ 'Content-Type':'application/json' }, headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ content: updatedHistory.at(-1)?.content || '', context }) body: JSON.stringify({ content: updatedHistory.at(-1)?.content || '', context })
}); });
const data = await r.json(); let reply = 'Sorry, something went wrong.';
const reply = (data?.reply || '').trim() || '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 }]); setMessages(prev => [...prev, { role:'assistant', content: reply }]);
} catch (e) { } catch (e) {
console.error(e); console.error(e);

View File

@ -84,7 +84,10 @@ export default function CareerProfileForm() {
}) })
}); });
if (!res.ok) throw new Error(await res.text()); 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) { } catch (err) {
console.error(err); console.error(err);
alert(err.message); alert(err.message);

View File

@ -395,7 +395,7 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const chat = useContext(ChatCtx) || {}; const chat = useContext(ChatCtx) || {};
const setChatSnapshot = chat?.setChatSnapshot; const setChatSnapshot = chat?.setChatSnapshot;
const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null; const dataReady = !!scenarioRow && !!financialProfile && collegeProfile !== null; // unchanged; {} is truthy
const reloadScenarioAndCollege = useCallback(async () => { const reloadScenarioAndCollege = useCallback(async () => {
if (!careerProfileId) return; if (!careerProfileId) return;
@ -409,10 +409,24 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
setScenarioRow(row); setScenarioRow(row);
} }
const c = await authFetch( const c = await authFetch(`/api/premium/college-profile?careerProfileId=${encodeURIComponent(careerProfileId)}`);
`/api/premium/college-profile?careerProfileId=${careerProfileId}` if (c.status === 404) {
); setCollegeProfile({}); // no profile yet → use defaults
if (c.ok) setCollegeProfile(await c.json()); 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]); }, [careerProfileId]);
const milestoneGroups = useMemo(() => { const milestoneGroups = useMemo(() => {
@ -514,15 +528,16 @@ useEffect(() => {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const up = await authFetch('/api/user-profile'); 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'); const fp = await authFetch('/api/premium/financial-profile');
if (fp.status === 404) { if (fp.status === 404) {
// user skipped onboarding treat as empty object // user skipped onboarding treat as empty object
setFinancialProfile({}); setFinancialProfile({});
} else if (fp.ok) { } else if (fp.ok && (fp.headers.get('content-type')||'').includes('application/json')) {
setFinancialProfile(await fp.json()); setFinancialProfile(await fp.json());
} }
})(); })();
}, []); }, []);
@ -689,7 +704,92 @@ useEffect(() => {
let cancelled = false; let cancelled = false;
(async function init () { (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'); const r = await authFetch('/api/premium/career-profile/all');
if (!r?.ok || cancelled) return; if (!r?.ok || cancelled) return;
const { careerProfiles=[] } = await r.json(); const { careerProfiles=[] } = await r.json();
@ -943,10 +1043,10 @@ useEffect(() => {
{ signal: ctrl.signal } { signal: ctrl.signal }
); );
if (res.ok) { if (res.ok && (res.headers.get('content-type')||'').includes('application/json')) {
setEconomicProjections(await res.json()); setEconomicProjections(await res.json());
setEconLoading(false); setEconLoading(false);
} else { } else {
console.error('[Econ fetch]', res.status); console.error('[Econ fetch]', res.status);
setEconLoading(false); setEconLoading(false);
} }
@ -1101,7 +1201,7 @@ if (allMilestones.length) {
annualFinancialAid: collegeData.annualFinancialAid, annualFinancialAid: collegeData.annualFinancialAid,
calculatedTuition: collegeData.calculatedTuition, calculatedTuition: collegeData.calculatedTuition,
extraPayment: collegeData.extraPayment, extraPayment: collegeData.extraPayment,
enrollmentDate: collegeProfile.enrollment_Date || null, enrollmentDate: collegeProfile.enrollment_date || null,
inCollege: collegeData.inCollege, inCollege: collegeData.inCollege,
gradDate: collegeData.gradDate, gradDate: collegeData.gradDate,
programType: collegeData.programType, programType: collegeData.programType,
@ -1138,7 +1238,7 @@ if (allMilestones.length) {
} }
useEffect(() => { useEffect(() => {
if (!financialProfile || !scenarioRow || !collegeProfile) return; if (!financialProfile || !scenarioRow || collegeProfile === null) return;
fetchMilestones(); fetchMilestones();
}, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]); }, [financialProfile, scenarioRow, collegeProfile, careerProfileId, simulationYears, interestStrategy, flatAnnualRate, randomRangeMin, randomRangeMax]);
@ -1284,16 +1384,11 @@ const currentIdRef = useRef(null);
const fetchMilestones = useCallback(async () => { const fetchMilestones = useCallback(async () => {
if (!careerProfileId) return; if (!careerProfileId) return;
const [profRes, uniRes] = await Promise.all([ const profRes = await authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`);
authFetch(`/api/premium/milestones?careerProfileId=${careerProfileId}`), if (!profRes.ok) return;
authFetch(`/api/premium/milestones?careerProfileId=universal`) const ct = profRes.headers.get('content-type') || '';
]); const { milestones: profMs = [] } = ct.includes('application/json') ? await profRes.json() : { milestones: [] };
if (!profRes.ok || !uniRes.ok) return; const merged = profMs;
const [{ milestones: profMs }, { milestones: uniMs }] =
await Promise.all([profRes.json(), uniRes.json()]);
const merged = [...profMs, ...uniMs];
setScenarioMilestones(merged); setScenarioMilestones(merged);
if (financialProfile && scenarioRow && collegeProfile) { if (financialProfile && scenarioRow && collegeProfile) {
buildProjection(merged); buildProjection(merged);

View File

@ -172,10 +172,21 @@ function normalizeCipList(arr) {
const proceed = window.confirm( const proceed = window.confirm(
'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' 'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?'
); );
if (proceed) { if (!proceed) return;
navigate('/career-roadmap', { state: { selectedSchool: school } }); // 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) { function getSearchLinks(ksaName, careerTitle) {
const combinedQuery = `${careerTitle} ${ksaName}`.trim(); const combinedQuery = `${careerTitle} ${ksaName}`.trim();