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 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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------------
|
/* ------------------------------------------------------------------
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
@ -172,10 +172,21 @@ function normalizeCipList(arr) {
|
|||||||
const proceed = window.confirm(
|
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?'
|
'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) {
|
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();
|
||||||
|
Loading…
Reference in New Issue
Block a user