final fixes before rotating secrets: college profile ciphertext, selectedCareer/premiumonboardingstate carry into CareerRoadmap, etc.
This commit is contained in:
parent
5838f782e7
commit
7d27dc160c
@ -1 +1 @@
|
||||
fcb1ff42e88c57ae313a74da813f6a3cdb19904f-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
2aa2de355546f6401f80a07e00066bd83f37fdc6-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
@ -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 });
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
|
@ -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();
|
||||
|
Loading…
Reference in New Issue
Block a user