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 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 });
});
/* ------------------------------------------------------------------

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -172,10 +172,21 @@ function normalizeCipList(arr) {
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?'
);
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();