From e943f1c427536538105f85fc26a58ca168d9c391 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Sep 2025 16:18:33 +0000 Subject: [PATCH] Network tab hides, Reload Career Suggestion performance --- .build.hash | 2 +- backend/server1.js | 130 +- backend/server2.js | 431 ++++-- backend/server3.js | 577 +++++-- backend/shared/requireAuth.js | 73 +- backend/tests/regression.mjs | 142 ++ docker-compose.yml | 1 + nginx.conf | 106 +- src/App.js | 79 +- src/components/BillingResult.js | 98 +- src/components/CareerExplorer.js | 1342 +++++++---------- src/components/CareerProfileList.js | 43 +- src/components/CareerRoadmap.js | 84 +- src/components/CollegeProfileList.js | 45 +- src/components/EducationalProgramsPage.js | 19 +- src/components/InterestInventory.js | 19 +- .../PremiumOnboarding/CareerOnboarding.js | 77 +- .../PremiumOnboarding/CollegeOnboarding.js | 165 +- .../PremiumOnboarding/FinancialOnboarding.js | 66 +- .../PremiumOnboarding/OnboardingContainer.js | 123 +- src/components/SignIn.js | 35 +- src/components/SignInLanding.js | 10 +- src/components/SupportModal.js | 42 +- src/components/UserProfile.js | 9 +- src/utils/onboardingDraftApi.js | 70 +- tests/smoke.sh | 38 + 26 files changed, 2293 insertions(+), 1533 deletions(-) create mode 100644 backend/tests/regression.mjs create mode 100644 tests/smoke.sh diff --git a/.build.hash b/.build.hash index ca31f14..d3348bf 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -fb83dd6424562765662889aea6436fdb4b1b975f-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b +c8af44caf3dec8c5f306fef35c4925be044f0374-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/backend/server1.js b/backend/server1.js index 3804c0d..171cdfd 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -787,78 +787,41 @@ const signinLimiter = rateLimit({ windowMs: 15*60*1000, max: 50, standardHeaders app.post('/api/signin', signinLimiter, async (req, res) => { const { username, password } = req.body; if (!username || !password) { - return res - .status(400) - .json({ error: 'Both username and password are required' }); + return res.status(400).json({ error: 'Both username and password are required' }); } - // SELECT only the columns you actually have: - // 'ua.id' is user_auth's primary key, - // 'ua.user_id' references user_profile.id, - // and we alias user_profile.id as profileId for clarity. + // Only fetch what you need to verify creds const query = ` - SELECT - ua.id AS authId, - ua.user_id AS userProfileId, - ua.hashed_password, - up.firstname, - up.lastname, - up.email, - up.zipcode, - up.state, - up.area, - up.career_situation + SELECT ua.user_id AS userProfileId, ua.hashed_password FROM user_auth ua - LEFT JOIN user_profile up ON ua.user_id = up.id WHERE ua.username = ? + LIMIT 1 `; try { const [results] = await pool.query(query, [username]); - if (!results || results.length === 0) { return res.status(401).json({ error: 'Invalid username or password' }); } - const row = results[0]; - - // Compare password with bcrypt - const isMatch = await bcrypt.compare(password, row.hashed_password); + const { userProfileId, hashed_password } = results[0]; + const isMatch = await bcrypt.compare(password, hashed_password); if (!isMatch) { return res.status(401).json({ error: 'Invalid username or password' }); } - // Return user info + token - // 'authId' is user_auth's PK, but typically you won't need it on the client - // 'row.userProfileId' is the actual user_profile.id - const [profileRows] = await pool.query( - 'SELECT firstname, lastname, email, zipcode, state, area, career_situation \ - FROM user_profile WHERE id = ?', - [row.userProfileId] - ); - const profile = profileRows[0]; -if (profile?.email) { - try { profile.email = decrypt(profile.email); } catch {} -} + // Cookie-based session only; do NOT return id/token/user in body + const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' }); + res.cookie(COOKIE_NAME, token, sessionCookieOptions()); - -const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' }); -res.cookie(COOKIE_NAME, token, sessionCookieOptions()); - - res.status(200).json({ - message: 'Login successful', - token, - id: row.userProfileId, - user: profile - }); + return res.status(200).json({ message: 'Login successful' }); } catch (err) { console.error('Error querying user_auth:', err.message); - return res - .status(500) - .json({ error: 'Failed to query user authentication data' }); + return res.status(500).json({ error: 'Failed to query user authentication data' }); } }); + app.post('/api/logout', (_req, res) => { res.clearCookie(COOKIE_NAME, sessionCookieOptions()); return res.status(200).json({ ok: true }); @@ -1031,29 +994,70 @@ app.post('/api/user-profile', requireAuth, async (req, res) => { /* ------------------------------------------------------------------ - FETCH USER PROFILE (MySQL) - ------------------------------------------------------------------ */ + FETCH USER PROFILE (MySQL) — safe, minimal, no id +------------------------------------------------------------------ */ app.get('/api/user-profile', requireAuth, async (req, res) => { - const profileId = req.userId; // from requireAuth middleware - + const profileId = req.userId; try { - const [results] = await pool.query('SELECT * FROM user_profile WHERE id = ?', [profileId]); - if (!results || results.length === 0) { - return res.status(404).json({ error: 'User profile not found' }); + // Optional minimal-field mode: /api/user-profile?fields=a,b,c + const raw = (req.query.fields || '').toString().trim(); + + if (raw) { + // No 'id' here on purpose + const ALLOW = new Set([ + 'username','firstname','lastname','career_situation', + 'is_premium','is_pro_premium', + 'state','area','zipcode', + 'career_priorities','interest_inventory_answers','riasec_scores','career_list', + 'email', + 'phone_e164', + 'sms_opt_in' + ]); + + const requested = raw.split(',').map(s => s.trim()).filter(Boolean); + const cols = requested.filter(c => ALLOW.has(c)); + if (cols.length === 0) { + return res.status(400).json({ error: 'no_allowed_fields' }); + } + + const sql = `SELECT ${cols.join(', ')} FROM user_profile WHERE id = ? LIMIT 1`; + const [rows] = await pool.query(sql, [profileId]); + const row = rows && rows[0] ? rows[0] : null; // <-- declare BEFORE using + + if (!row) return res.status(404).json({ error: 'User profile not found' }); + + // Decrypt only if explicitly requested and present + if (cols.includes('email') && row.email) { + try { row.email = decrypt(row.email); } catch {} + } + // Phone may be encrypted; normalize sms_opt_in to boolean + if (cols.includes('phone_e164') && typeof row.phone_e164 === 'string' && row.phone_e164.startsWith('gcm:')) { + try { row.phone_e164 = decrypt(row.phone_e164); } catch {} + } + if (cols.includes('sms_opt_in')) { + row.sms_opt_in = !!row.sms_opt_in; // BIT/TINYINT → boolean + } + + return res.status(200).json(row); } - const row = results[0]; - if (row?.email) { - try { row.email = decrypt(row.email); } catch {} - } - res.status(200).json(row); + + // Legacy fallback: return only something harmless (no id/email) + const [rows] = await pool.query( + 'SELECT firstname FROM user_profile WHERE id = ? LIMIT 1', + [profileId] + ); + const row = rows && rows[0] ? rows[0] : null; + if (!row) return res.status(404).json({ error: 'User profile not found' }); + return res.status(200).json(row); } catch (err) { - console.error('Error fetching user profile:', err.message); - res.status(500).json({ error: 'Internal server error' }); + console.error('Error fetching user profile:', err?.message || err); + return res.status(500).json({ error: 'Internal server error' }); } }); + /* ------------------------------------------------------------------ SALARY_INFO REMAINS IN SQLITE ------------------------------------------------------------------ */ diff --git a/backend/server2.js b/backend/server2.js index 9e9dba1..049c5fa 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -60,9 +60,6 @@ const chatLimiter = rateLimit({ keyGenerator: req => req.user?.id || req.ip }); - - - // ── RUNTIME PROTECTION: outbound host allowlist (server2) ── const OUTBOUND_ALLOW = new Set([ 'services.onetcenter.org', // O*NET @@ -199,6 +196,7 @@ const EXEMPT_PATHS = [ app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); + if (isHotReloadPath(req)) return next(); if (!MUST_JSON.has(req.method)) return next(); if (EXEMPT_PATHS.some(rx => rx.test(req.path))) return next(); @@ -330,18 +328,21 @@ process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e) process.on('uncaughtException', (e) => console.error('[uncaughtException]', e)); + // ---- RUNTIME PROTECTION: HPP guard (dedupe + cap arrays) ---- app.use((req, _res, next) => { - const MAX_ARRAY = 20; // sane cap; adjust if you truly need more + // Bypass guard on hot reload routes to avoid slicing/false negatives + if (isHotReloadPath(req)) return next(); + + const MAX_ARRAY = 20; // keep stricter cap elsewhere const sanitize = (obj) => { if (!obj || typeof obj !== 'object') return; for (const k of Object.keys(obj)) { const v = obj[k]; if (Array.isArray(v)) { - // keep first value semantics + bound array size obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null); - if (obj[k].length === 1) obj[k] = obj[k][0]; // collapse singletons + if (obj[k].length === 1) obj[k] = obj[k][0]; } } }; @@ -353,6 +354,7 @@ app.use((req, _res, next) => { // ---- RUNTIME: reject request bodies on GET/HEAD ---- app.use((req, res, next) => { + if (isHotReloadPath(req)) return next(); if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) { return res.status(400).json({ error: 'no_body_allowed' }); } @@ -379,6 +381,16 @@ app.get('/readyz', async (_req, res) => { } }); +const isHotReloadPath = (req) => { + if (!req || !req.path) return false; + if (req.path.startsWith('/api/onet/')) return true; + if (req.path.startsWith('/api/cip/')) return true; + if (req.path.startsWith('/api/projections/')) return true; + if (req.path.startsWith('/api/salary')) return true; + if (req.method === 'POST' && req.path === '/api/job-zones') return true; + return false; +}; + // 3) Health: detailed JSON you can curl app.get('/healthz', async (_req, res) => { const out = { @@ -483,6 +495,10 @@ const supportDailyLimiter = rateLimit({ * DB connections (SQLite) **************************************************/ let dbSqlite; +// Salary fast path: prepared stmt + tiny LRU cache +let SALARY_STMT = null; +const SALARY_CACHE = new Map(); // key: `${occ}|${area}` -> result +const SALARY_CACHE_MAX = 512; let userProfileDb; async function initDatabases() { @@ -493,6 +509,30 @@ async function initDatabases() { mode : sqlite3.OPEN_READONLY }); console.log('✅ Connected to salary_info.db'); + // Light PRAGMAs safe for read-only workload + await dbSqlite.exec(` + PRAGMA busy_timeout=4000; + PRAGMA journal_mode=OFF; + PRAGMA synchronous=OFF; + PRAGMA temp_store=MEMORY; + `); + // One prepared statement: regional (param) + national in a single scan + SALARY_STMT = await dbSqlite.prepare(` + SELECT + MAX(CASE WHEN AREA_TITLE = ? THEN A_PCT10 END) AS regional_PCT10, + MAX(CASE WHEN AREA_TITLE = ? THEN A_PCT25 END) AS regional_PCT25, + MAX(CASE WHEN AREA_TITLE = ? THEN A_MEDIAN END) AS regional_MEDIAN, + MAX(CASE WHEN AREA_TITLE = ? THEN A_PCT75 END) AS regional_PCT75, + MAX(CASE WHEN AREA_TITLE = ? THEN A_PCT90 END) AS regional_PCT90, + MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_PCT10 END) AS national_PCT10, + MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_PCT25 END) AS national_PCT25, + MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_MEDIAN END) AS national_MEDIAN, + MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_PCT75 END) AS national_PCT75, + MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_PCT90 END) AS national_PCT90 + FROM salary_data + WHERE OCC_CODE = ? + AND (AREA_TITLE = ? OR AREA_TITLE = 'U.S.') + `); userProfileDb = await open({ filename: USER_PROFILE_DB_PATH, @@ -563,16 +603,72 @@ app.use((req, res, next) => next()); // ──────────────────────────────── Data endpoints ─────────────────────────────── -// /api/data/careers-with-ratings → backend/data/careers_with_ratings.json -app.get('/api/data/careers-with-ratings', (req, res) => { - const p = path.join(DATA_DIR, 'careers_with_ratings.json'); - fs.access(p, fs.constants.R_OK, (err) => { - if (err) return res.status(404).json({ error: 'careers_with_ratings.json not found' }); - res.type('application/json'); - res.sendFile(p); - }); +/************************************************** + * BULK limited-data computation (single call) + * - Input: { socCodes: [full SOCs], state, area } + * - Output: { [fullSoc]: { job_zone, limitedData } } + **************************************************/ +app.post('/api/suggestions/limited-data', async (req, res) => { + try { + const socCodes = Array.isArray(req.body?.socCodes) ? req.body.socCodes : []; + const stateIn = String(req.body?.state || ''); + const area = String(req.body?.area || ''); + if (!socCodes.length) return res.json({}); + + const fullState = fullStateFrom(stateIn); + + // Pre-dedupe base SOCs for base-scoped work (projections, salary, job_zone) + const bases = [...new Set(socCodes.map(baseSocOf))]; + + // Precompute base-scoped booleans/zones + const projMap = new Map(); + const salMap = new Map(); + const zoneMap = new Map(); + + await Promise.all(bases.map(async (b) => { + const [hasProj, hasSal, zone] = await Promise.all([ + Promise.resolve(projectionsExist(b, fullState)), + salaryExist(b, area), + getJobZone(b) + ]); + projMap.set(b, !!hasProj); + salMap.set(b, !!hasSal); + zoneMap.set(b, zone ?? null); + })); + + // Build per-full-SOC answers (CIP + O*NET desc presence) + const out = {}; + for (const fullSoc of socCodes) { + const base = baseSocOf(fullSoc); + + const hasCip = hasCipForFullSoc(fullSoc); + + // O*NET presence (from our tiny cache table). If unknown → treat as false for now + // (conservative; first modal open will populate and future runs will be correct). + let hasDesc = false; + let hasTasks = false; + try { + const p = await onetDescPresence(fullSoc); + if (p) { hasDesc = !!p.has_desc; hasTasks = !!p.has_tasks; } + } catch {} + + const hasProj = !!projMap.get(base); + const hasSal = !!salMap.get(base); + const job_zone = zoneMap.get(base) ?? null; + + const limitedData = !(hasCip && (hasDesc || hasTasks) && hasProj && hasSal); + + out[fullSoc] = { job_zone, limitedData }; + } + + return res.json(out); + } catch (e) { + console.error('[limited-data bulk]', e?.message || e); + return res.status(500).json({ error: 'failed' }); + } }); + // /api/data/cip-institution-map → backend/data/cip_institution_mapping_new.json (or fallback) app.get('/api/data/cip-institution-map', (req, res) => { const candidates = [ @@ -621,6 +717,13 @@ const socToCipMapping = loadMapping(); if (socToCipMapping.length === 0) { console.error('SOC to CIP mapping data is empty.'); } +// O(1) CIP index: FULL SOC -> CIP code (first match wins) +const CIP_BY_SOC = new Map(); +for (const row of socToCipMapping) { + const soc = String(row['O*NET-SOC 2019 Code'] || '').trim(); + const cip = row['2020 CIP Code']; + if (soc && cip && !CIP_BY_SOC.has(soc)) CIP_BY_SOC.set(soc, cip); +} /************************************************** * Load single JSON with all states + US @@ -636,6 +739,25 @@ try { console.error('Error reading economicproj.json:', err); } +// O(1) projections index: key = `${occ}|${areaLower}` +const PROJ_BY_KEY = new Map(); +for (const r of allProjections) { + const occ = String(r['Occupation Code'] || '').trim(); + const area = String(r['Area Name'] || '').trim().toLowerCase(); + if (!occ || !area) continue; + PROJ_BY_KEY.set(`${occ}|${area}`, { + area : r['Area Name'], + baseYear : r['Base Year'], + base : r['Base'], + projectedYear : r['Projected Year'], + projection : r['Projection'], + change : r['Change'], + percentChange : r['Percent Change'], + annualOpenings: r['Average Annual Openings'], + occupationName: r['Occupation Name'], + }); +} + //AI At Risk helpers async function getRiskAnalysisFromDB(socCode) { const row = await userProfileDb.get( @@ -645,6 +767,96 @@ async function getRiskAnalysisFromDB(socCode) { return row || null; } + +/* ────────────────────────────────────────────────────────────── + * Helpers used by /api/onet/submit_answers for limited_data + * - Uses ONLY local sources you already load: + * • socToCipMapping (Excel → in-memory array) + * • allProjections (economicproj.json → in-memory array) + * • dbSqlite (salary_info.db → SQLite handle) + * - No external HTTP calls. No logging of SOCs. + * ────────────────────────────────────────────────────────────── */ + +const baseSocOf = (fullSoc = '') => String(fullSoc).split('.')[0]; // "15-1252.01" → "15-1252" + +const fullStateFrom = (s = '') => { + const M = { + AL:'Alabama', AK:'Alaska', AZ:'Arizona', AR:'Arkansas', CA:'California', CO:'Colorado', + CT:'Connecticut', DE:'Delaware', DC:'District of Columbia', FL:'Florida', GA:'Georgia', + HI:'Hawaii', ID:'Idaho', IL:'Illinois', IN:'Indiana', IA:'Iowa', KS:'Kansas', + KY:'Kentucky', LA:'Louisiana', ME:'Maine', MD:'Maryland', MA:'Massachusetts', + MI:'Michigan', MN:'Minnesota', MS:'Mississippi', MO:'Missouri', MT:'Montana', + NE:'Nebraska', NV:'Nevada', NH:'New Hampshire', NJ:'New Jersey', NM:'New Mexico', + NY:'New York', NC:'North Carolina', ND:'North Dakota', OH:'Ohio', OK:'Oklahoma', + OR:'Oregon', PA:'Pennsylvania', RI:'Rhode Island', SC:'South Carolina', + SD:'South Dakota', TN:'Tennessee', TX:'Texas', UT:'Utah', VT:'Vermont', + VA:'Virginia', WA:'Washington', WV:'West Virginia', WI:'Wisconsin', WY:'Wyoming' + }; + if (!s) return ''; + const up = String(s).trim().toUpperCase(); + return M[up] || s; // already full name → return as-is +}; + +/** CIP presence from your loaded Excel mapping (exact FULL SOC match) */ +const hasCipForFullSoc = (fullSoc = '') => { + const want = String(fullSoc).trim(); + for (const row of socToCipMapping) { + if (String(row['O*NET-SOC 2019 Code'] || '').trim() === want) return true; + } + return false; +}; + +/** Projections presence from economicproj.json + * True if a row exists for base SOC in the requested state (full name) OR in "United States". + */ +function projectionsExist(baseSoc, fullState) { + const code = String(baseSoc).trim(); + const wantState = (fullState || '').trim().toLowerCase(); + const hasState = wantState + ? allProjections.some(r => + String(r['Occupation Code']).trim() === code && + String(r['Area Name'] || '').trim().toLowerCase() === wantState + ) + : false; + + const hasUS = allProjections.some(r => + String(r['Occupation Code']).trim() === code && + String(r['Area Name'] || '').trim().toLowerCase() === 'united states' + ); + return hasState || hasUS; +} + +/** Salary presence from salary_info.db + * True if regional row exists (base SOC + AREA_TITLE == area); otherwise fallback to U.S. + */ +async function salaryExist(baseSoc, area) { + const occ = String(baseSoc).trim(); + const a = String(area || '').trim(); + if (a) { + const regional = await dbSqlite.get( + `SELECT 1 FROM salary_data WHERE OCC_CODE = ? AND AREA_TITLE = ? LIMIT 1`, + [occ, a] + ); + if (regional) return true; + } + const national = await dbSqlite.get( + `SELECT 1 FROM salary_data WHERE OCC_CODE = ? AND AREA_TITLE = 'U.S.' LIMIT 1`, + [occ] + ); + return !!national; +} + +/** Compute limited_data exactly once for a given career row */ +async function computeLimitedFor(fullSoc, stateAbbrevOrName, areaTitle) { + const base = baseSocOf(fullSoc); + const fullSt = fullStateFrom(stateAbbrevOrName) || 'United States'; + const hasCip = hasCipForFullSoc(fullSoc); + const hasProj = projectionsExist(base, fullSt); + const hasSal = await salaryExist(base, areaTitle); + return !(hasCip && hasProj && hasSal); +} + + // Helper to upsert a row async function storeRiskAnalysisInDB({ socCode, @@ -970,18 +1182,11 @@ app.get('/api/onet/career-description/:socCode', async (req, res) => { // CIP route app.get('/api/cip/:socCode', (req, res) => { const { socCode } = req.params; - console.log(`Received SOC Code: ${socCode.trim()}`); - - for (let row of socToCipMapping) { - const mappedSOC = row['O*NET-SOC 2019 Code']?.trim(); - if (mappedSOC === socCode.trim()) { - console.log('Found CIP code:', row['2020 CIP Code']); - return res.json({ cipCode: row['2020 CIP Code'] }); - } - } - console.error('SOC code not found in mapping:', socCode); - res.status(404).json({ error: 'CIP code not found' }); -}); + const key = String(socCode || '').trim(); + const cip = CIP_BY_SOC.get(key); + if (cip) return res.json({ cipCode: cip }); + return res.status(404).json({ error: 'CIP code not found' }); + }); /** @aiTool { "name": "getSchoolsForCIPs", @@ -1142,60 +1347,19 @@ app.get('/api/tuition', (req, res) => { /************************************************** * SINGLE route for projections from economicproj.json **************************************************/ -app.get('/api/projections/:socCode', (req, res) => { - const { socCode } = req.params; - const { state } = req.query; - console.log('Projections request for', socCode, ' state=', state); - - if (!socCode) { - return res.status(400).json({ error: 'SOC Code is required.' }); + app.get('/api/projections/:socCode', (req, res) => { + const { socCode } = req.params; + const { state } = req.query; + const occ = String(socCode).trim(); + const areaKey = String(state ? state : 'United States').trim().toLowerCase(); + const rowState = PROJ_BY_KEY.get(`${occ}|${areaKey}`) || null; + const rowUS = PROJ_BY_KEY.get(`${occ}|united states`) || null; + if (!rowState && !rowUS) { + return res.status(404).json({ error: 'No projections found for this SOC + area.' }); } + return res.json({ state: rowState, national: rowUS }); + }); - // If no ?state=, default to "United States" - const areaName = state ? state.trim() : 'United States'; - - // Find the row for the requested area - const rowForState = allProjections.find( - (row) => - row['Occupation Code'] === socCode.trim() && - row['Area Name']?.toLowerCase() === areaName.toLowerCase() - ); - - // Also find the row for "United States" - const rowForUS = allProjections.find( - (row) => - row['Occupation Code'] === socCode.trim() && - row['Area Name']?.toLowerCase() === 'united states' - ); - - if (!rowForState && !rowForUS) { - return res - .status(404) - .json({ error: 'No projections found for this SOC + area.' }); - } - - function formatRow(r) { - if (!r) return null; - return { - area: r['Area Name'], - baseYear: r['Base Year'], - base: r['Base'], - projectedYear: r['Projected Year'], - projection: r['Projection'], - change: r['Change'], - percentChange: r['Percent Change'], - annualOpenings: r['Average Annual Openings'], - occupationName: r['Occupation Name'], - }; - } - - const result = { - state: formatRow(rowForState), - national: formatRow(rowForUS), - }; - - return res.json(result); -}); /** @aiTool { "name": "getSalaryData", @@ -1217,49 +1381,51 @@ app.get('/api/projections/:socCode', (req, res) => { **************************************************/ app.get('/api/salary', async (req, res) => { const { socCode, area } = req.query; - console.log('Received /api/salary request:', { socCode, area }); if (!socCode) { return res.status(400).json({ error: 'SOC Code is required' }); } - - const regionalQuery = ` - SELECT A_PCT10 AS regional_PCT10, - A_PCT25 AS regional_PCT25, - A_MEDIAN AS regional_MEDIAN, - A_PCT75 AS regional_PCT75, - A_PCT90 AS regional_PCT90 - FROM salary_data - WHERE OCC_CODE = ? AND AREA_TITLE = ? - `; - const nationalQuery = ` - SELECT A_PCT10 AS national_PCT10, - A_PCT25 AS national_PCT25, - A_MEDIAN AS national_MEDIAN, - A_PCT75 AS national_PCT75, - A_PCT90 AS national_PCT90 - FROM salary_data - WHERE OCC_CODE = ? AND AREA_TITLE = 'U.S.' - `; - try { - let regionalRow = null; - let nationalRow = null; + const keyArea = String(area || ''); // allow empty → national only + const cacheKey = `${socCode}|${keyArea}`; + const cached = SALARY_CACHE.get(cacheKey); + if (cached) return res.json(cached); - if (area) { - regionalRow = await dbSqlite.get(regionalQuery, [socCode, area]); - } - nationalRow = await dbSqlite.get(nationalQuery, [socCode]); + // Bind regional placeholders (five times) + occ + area + const row = await SALARY_STMT.get( + keyArea, keyArea, keyArea, keyArea, keyArea, socCode, keyArea + ); - if (!regionalRow && !nationalRow) { - console.log('No salary data found for:', { socCode, area }); + const regional = { + regional_PCT10 : row?.regional_PCT10 ?? undefined, + regional_PCT25 : row?.regional_PCT25 ?? undefined, + regional_MEDIAN : row?.regional_MEDIAN ?? undefined, + regional_PCT75 : row?.regional_PCT75 ?? undefined, + regional_PCT90 : row?.regional_PCT90 ?? undefined, + }; + const national = { + national_PCT10 : row?.national_PCT10 ?? undefined, + national_PCT25 : row?.national_PCT25 ?? undefined, + national_MEDIAN : row?.national_MEDIAN ?? undefined, + national_PCT75 : row?.national_PCT75 ?? undefined, + national_PCT90 : row?.national_PCT90 ?? undefined, + }; + + // If both are empty, 404 to match old behavior + if ( + Object.values(regional).every(v => v == null) && + Object.values(national).every(v => v == null) + ) { return res.status(404).json({ error: 'No salary data found' }); } - const salaryData = { - regional: regionalRow || {}, - national: nationalRow || {}, - }; - console.log('Salary data retrieved:', salaryData); - res.json(salaryData); + + const payload = { regional, national }; + // Tiny LRU: cap at 512 entries + SALARY_CACHE.set(cacheKey, payload); + if (SALARY_CACHE.size > SALARY_CACHE_MAX) { + const first = SALARY_CACHE.keys().next().value; + SALARY_CACHE.delete(first); + } + res.json(payload); } catch (error) { console.error('Error executing salary query:', error.message); res.status(500).json({ error: 'Failed to fetch salary data' }); @@ -1376,27 +1542,6 @@ app.get('/api/skills/:socCode', async (req, res) => { } }); -/************************************************** - * user-profile by ID route - **************************************************/ -app.get('/api/user-profile/:id', (req, res) => { - const { id } = req.params; - if (!id) return res.status(400).json({ error: 'Profile ID is required' }); - - const query = `SELECT area, zipcode FROM user_profile WHERE id = ?`; - userProfileDb.get(query, [id], (err, row) => { - if (err) { - console.error('Error fetching user profile:', err.message); - return res.status(500).json({ error: 'Failed to fetch user profile' }); - } - if (!row) { - return res.status(404).json({ error: 'Profile not found' }); - } - res.json({ area: row.area, zipcode: row.zipcode }); - }); -}); - - /*************************************************** * AI RISK ASSESSMENT ENDPOINT READ ****************************************************/ @@ -1498,9 +1643,25 @@ app.post( accountEmail = row?.email || null; } catch {} } + + // If still missing, fetch from server1 using the caller's session if (!accountEmail) { - accountEmail = (req.body && req.body.email) || null; + try { + const r = await fetch('http://server1:5000/api/user-profile?fields=email', { + method: 'GET', + headers: { + // forward caller's auth — either cookie (HttpOnly session) or bearer + 'Authorization': req.headers.authorization || '', + 'Cookie': req.headers.cookie || '' + } + }); + if (r.ok && (r.headers.get('content-type') || '').includes('application/json')) { + const j = await r.json(); + accountEmail = j?.email || null; + } + } catch { /* best-effort; fall through to error below if still null */ } } + if (!accountEmail) { return res.status(400).json({ error: 'No email on file for this user' }); } diff --git a/backend/server3.js b/backend/server3.js index 3877290..d0b97e8 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -47,11 +47,7 @@ const API_BASE = `${INTERNAL_SELF_BASE}/api`; const DATA_DIR = path.join(__dirname, 'data'); /* ─── helper: canonical public origin ─────────────────────────── */ -const PUBLIC_BASE = ( - process.env.APTIVA_AI_BASE - || process.env.REACT_APP_API_URL - || '' -).replace(/\/+$/, ''); +const PUBLIC_BASE = (process.env.APTIVA_API_BASE || '').replace(/\/+$/, ''); const ALLOWED_REDIRECT_HOSTS = new Set([ new URL(PUBLIC_BASE || 'http://localhost').host @@ -60,7 +56,7 @@ const ALLOWED_REDIRECT_HOSTS = new Set([ // ── RUNTIME PROTECTION: outbound host allowlist (server3) ── const OUTBOUND_ALLOW = new Set([ 'server2', // compose DNS (server2:5001) - 'server3', // self-calls (localhost:5002) + 'server3', // self-calls (server3:5002) 'api.openai.com', // OpenAI SDK traffic 'api.stripe.com', // Stripe SDK traffic 'api.twilio.com' // smsService may hit Twilio from this proc @@ -93,9 +89,10 @@ const app = express(); app.use(cookieParser()); app.disable('x-powered-by'); app.set('trust proxy', 1); -app.use(express.json({ limit: '1mb' })); + app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false })); + // --- Request ID + minimal audit log for /api/* --- function getRequestId(req, res) { const hdr = req.headers['x-request-id']; @@ -507,6 +504,34 @@ async function getRiskAnalysisFromDB(socCode) { return rows.length > 0 ? rows[0] : null; } +function safeMilestoneRow(m) { + return { + id: m.id, + career_profile_id: m.career_profile_id, + title: m.title, + description: m.description, + date: m.date, + progress: m.progress, + status: m.status, + is_universal: m.is_universal ? 1 : 0, + origin_milestone_id: m.origin_milestone_id || null, + created_at: m.created_at, + updated_at: m.updated_at + }; +} +function safeTaskRow(t) { + return { + id: t.id, + milestone_id: t.milestone_id, + title: t.title, + description: t.description, + due_date: t.due_date, + status: t.status, + created_at: t.created_at, + updated_at: t.updated_at + }; +} + async function storeRiskAnalysisInDB({ socCode, careerName, @@ -558,25 +583,134 @@ const COOKIE_NAME = process.env.COOKIE_NAME || 'aptiva_session'; //*PremiumOnboarding draft // GET current user's draft app.get('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { - const [[row]] = await pool.query( - 'SELECT id, step, data FROM onboarding_drafts WHERE user_id=?', + const [[row]] = await pool.query( + `SELECT id, step, data + FROM onboarding_drafts + WHERE user_id=? + ORDER BY updated_at DESC, id DESC + LIMIT 1`, [req.id] ); return res.json(row || null); }); -// POST upsert draft +// POST upsert draft (ID-agnostic, partial merge, 1 draft per user) app.post('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { - const { id, step = 0, data = {} } = req.body || {}; - const draftId = id || uuidv4(); - await pool.query(` - INSERT INTO onboarding_drafts (user_id,id,step,data) - VALUES (?,?,?,?) - ON DUPLICATE KEY UPDATE step=VALUES(step), data=VALUES(data) - `, [req.id, draftId, step, JSON.stringify(data)]); - res.json({ id: draftId, step }); + try { + // ---- 0) Harden req.body and incoming shapes + const body = (req && typeof req.body === 'object' && req.body) ? req.body : {}; + let { id, step } = body; + + // Accept either {data:{careerData/financialData/collegeData}} or section keys at top level + let incoming = {}; + if (body.data != null) { + if (typeof body.data === 'string') { + try { incoming = JSON.parse(body.data); } catch { incoming = {}; } + } else if (typeof body.data === 'object') { + incoming = body.data; + } + } + // If callers provided sections directly (EducationalProgramsPage), + // lift them into the data envelope without crashing if body is blank. + ['careerData','financialData','collegeData'].forEach(k => { + if (Object.prototype.hasOwnProperty.call(body, k)) { + if (!incoming || typeof incoming !== 'object') incoming = {}; + incoming[k] = body[k]; + } + }); + + // ---- 1) Base draft: by id (if provided) else latest for this user + let base = null; + if (id) { + const [[row]] = await pool.query( + `SELECT id, step, data FROM onboarding_drafts WHERE user_id=? AND id=? LIMIT 1`, + [req.id, id] + ); + base = row || null; + } else { + const [[row]] = await pool.query( + `SELECT id, step, data + FROM onboarding_drafts + WHERE user_id=? + ORDER BY updated_at DESC, id DESC + LIMIT 1`, + [req.id] + ); + base = row || null; + } + + // ---- 2) Parse prior JSON safely + let prev = {}; + if (base?.data != null) { + try { + if (typeof base.data === 'string') prev = JSON.parse(base.data); + else if (Buffer.isBuffer(base.data)) prev = JSON.parse(base.data.toString('utf8')); + else if (typeof base.data === 'object') prev = base.data; + } catch { prev = {}; } + } + + // ---- 3) Section-wise shallow merge (prev + incoming) + const merged = mergeDraft(prev, (incoming && typeof incoming === 'object') ? incoming : {}); + + // ---- 3.5) Refuse empty drafts so we don't wipe real data + const isEmptyObject = (o) => o && typeof o === 'object' && !Array.isArray(o) && Object.keys(o).length === 0; + const allSubsectionsEmpty = + isEmptyObject(merged) || + ( + typeof merged === 'object' && + ['careerData','financialData','collegeData'].every(k => !merged[k] || isEmptyObject(merged[k])) + ); + if (allSubsectionsEmpty) { + return res.status(400).json({ error: 'empty_draft' }); + } + + console.log('[draft-upsert]', { + userId : req.id, + draftId : draftId, + step : finalStep, + incoming : Object.keys(incoming || {}).sort(), + mergedKeys: Object.keys(merged || {}).sort(), + }); + + // ---- 4) Final id/step and upsert + const draftId = base?.id || id || uuidv4(); + const finalStep = Number.isInteger(step) ? step : (parseInt(step,10) || base?.step || 0); + + await pool.query( + `INSERT INTO onboarding_drafts (user_id, id, step, data) + VALUES (?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + step = VALUES(step), + data = VALUES(data), + updated_at = CURRENT_TIMESTAMP`, + [req.id, draftId, finalStep, JSON.stringify(merged)] + ); + + return res.json({ id: draftId, step: finalStep }); + } catch (e) { + console.error('draft upsert failed:', e?.message || e); + return res.status(500).json({ error: 'draft_upsert_failed' }); + } }); +// unchanged +function mergeDraft(a = {}, b = {}) { + const out = { ...a }; + for (const k of Object.keys(b || {})) { + const left = a[k]; + const right = b[k]; + if ( + left && typeof left === 'object' && !Array.isArray(left) && + right && typeof right === 'object' && !Array.isArray(right) + ) { + out[k] = { ...left, ...right }; + } else { + out[k] = right; + } + } + return out; +} + // DELETE draft (after finishing / cancelling) app.delete('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { await pool.query('DELETE FROM onboarding_drafts WHERE user_id=?', [req.id]); @@ -598,32 +732,73 @@ app.post( console.error('⚠️ Bad Stripe signature', err.message); return res.status(400).end(); } + // Env guard: only handle events matching our env + const isProd = (process.env.NODE_ENV === 'prod'); + if (Boolean(event.livemode) !== isProd) { + console.warn('[Stripe] Ignoring webhook due to livemode mismatch', { livemode: event.livemode, isProd }); + return res.sendStatus(200); + } - const upFlags = async (customerId, premium, pro) => { + const upFlags = async (customerId, premium, pro) => { const h = hashForLookup(customerId); - console.log('[Stripe] upFlags', { customerId, premium, pro }); await pool.query( `UPDATE user_profile - SET is_premium = ?, is_pro_premium = ? - WHERE stripe_customer_id_hash = ?`, - [premium, pro, h] + SET is_premium = ?, is_pro_premium = ? + WHERE stripe_customer_id_hash = ?`, + [premium ? 1 : 0, pro ? 1 : 0, h] ); }; - switch (event.type) { - case 'customer.subscription.created': - case 'customer.subscription.updated': { - const sub = event.data.object; - const pid = sub.items.data[0].price.id; - const tier = [process.env.STRIPE_PRICE_PRO_MONTH, - process.env.STRIPE_PRICE_PRO_YEAR].includes(pid) - ? 'pro' : 'premium'; - await upFlags(sub.customer, tier === 'premium', tier === 'pro'); - break; + // Recompute flags from Stripe (source of truth) + const recomputeFlagsFromStripe = async (customerId) => { + const subs = await stripe.subscriptions.list({ + customer: customerId, + status: 'all', + limit: 100 + }); + // Consider only “active-like” states + const ACTIVE_STATES = new Set(['active']); + let hasPremium = false; + let hasPro = false; + // after computing hasPremium/hasPro + const activeCount = subs.data.filter(s => ACTIVE_STATES.has(s.status)).length; + if (activeCount > 1) { + console.warn('[Stripe] multiple active subs for customer', { customerId, activeCount }); } + + for (const s of subs.data) { + if (!ACTIVE_STATES.has(s.status)) continue; + for (const item of s.items.data) { + const pid = item.price.id; + if (pid === process.env.STRIPE_PRICE_PRO_MONTH || pid === process.env.STRIPE_PRICE_PRO_YEAR) { + hasPro = true; + } + if (pid === process.env.STRIPE_PRICE_PREMIUM_MONTH || pid === process.env.STRIPE_PRICE_PREMIUM_YEAR) { + hasPremium = true; + } + } + } + // If any Pro sub exists, Pro wins; otherwise Premium if any premium exists + // Pro implies premium access; premium only if no pro + const finalIsPro = hasPro ? 1 : 0; + const finalIsPremium = hasPro ? 1 : (hasPremium ? 1 : 0); + await upFlags(customerId, finalIsPremium, finalIsPro); + }; + + switch (event.type) { + case 'customer.subscription.created': + case 'customer.subscription.updated': case 'customer.subscription.deleted': { const sub = event.data.object; - await upFlags(sub.customer, 0, 0); + await recomputeFlagsFromStripe(sub.customer); + break; + } + case 'checkout.session.completed': { + // extra safety for some Stripe flows that rely on this event + const ses = event.data.object; + if (ses.customer) { + await recomputeFlagsFromStripe(ses.customer); + } break; } default: @@ -634,8 +809,11 @@ app.post( ); + // 2) Basic middlewares app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false })); + +//leave below Stripe webhook app.use(express.json({ limit: '5mb' })); /* ─── Require critical env vars ─────────────────────────────── */ @@ -684,10 +862,6 @@ function authenticatePremiumUser(req, res, next) { } }; -/** ------------------------------------------------------------------ - * Returns the user’s stripe_customer_id (or null) given req.id. - * Creates a new Stripe Customer & saves it if missing. - * ----------------------------------------------------------------- */ /** ------------------------------------------------------------------ * Returns the user’s Stripe customer‑id (decrypted) given req.id. @@ -726,6 +900,21 @@ async function getOrCreateStripeCustomerId(req) { return customer.id; } +// ── Stripe: detect if customer already has an active (or pending) sub ───────── +async function customerHasActiveSub(customerId) { + // keep it small; we only need to know if ≥1 exists + const list = await stripe.subscriptions.list({ + customer: customerId, + status: 'all', + limit: 5 + }); + return list.data.some(s => { + // treat cancel_at_period_end as still-active for gating + if (s.cancel_at_period_end) return true; + return ['active'].includes(s.status); + }); +} + /* ------------------------------------------------------------------ */ const priceMap = { @@ -856,17 +1045,16 @@ async function ensureDescriptionAndTasks({ socCode, jobDescription, tasks }) { // GET the latest selected career profile app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (req, res) => { try { - const sql = ` - SELECT - *, - start_date AS start_date - FROM career_profiles - WHERE user_id = ? - ORDER BY start_date DESC - LIMIT 1 - `; - const [rows] = await pool.query(sql, [req.id]); - res.json(rows[0] || {}); + const [rows] = await pool.query( + `SELECT * FROM career_profiles + WHERE user_id = ? + ORDER BY start_date DESC + LIMIT 1`, + [req.id] + ); + const row = rows[0] ? { ...rows[0] } : null; + if (row) delete row.user_id; + return res.json(row || {}); } catch (error) { console.error('Error fetching latest career profile:', error); res.status(500).json({ error: 'Failed to fetch latest career profile' }); @@ -878,8 +1066,12 @@ app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req, try { const sql = ` SELECT - *, - start_date AS start_date + id, + scenario_title, + career_name, + status, + DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date, + DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at FROM career_profiles WHERE user_id = ? ORDER BY start_date ASC @@ -910,7 +1102,9 @@ app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser, if (!rows[0]) { return res.status(404).json({ error: 'Career profile not found or not yours.' }); } - res.json(rows[0]); + const row = { ...rows[0] }; + delete row.user_id; // do not ship user_id + return res.json(row); } catch (error) { console.error('Error fetching single career profile:', error); res.status(500).json({ error: 'Failed to fetch career profile by ID.' }); @@ -2601,33 +2795,32 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => const id = uuidv4(); await pool.query(` - INSERT INTO milestones ( - id, - user_id, - career_profile_id, - title, - description, - date, - progress, - status, - is_universal - ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `, [ + INSERT INTO milestones ( id, - req.id, + user_id, career_profile_id, title, - description || '', + description, date, - progress || 0, - status || 'planned', - is_universal ? 1 : 0 - ]); + progress, + status, + is_universal + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `, [ + id, + req.id, + career_profile_id, + title, + description || '', + date, + progress || 0, + status || 'planned', + is_universal ? 1 : 0 + ]); createdMilestones.push({ id, - user_id: req.id, career_profile_id, title, description: description || '', @@ -2697,7 +2890,17 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) => is_universal: is_universal ? 1 : 0, tasks: [] }; - return res.status(201).json(newMilestone); + return res.status(201).json({ + id, + career_profile_id, + title, + description: description || '', + date, + progress: progress || 0, + status: status || 'planned', + is_universal: is_universal ? 1 : 0, + tasks: [] + }); } catch (err) { console.error('Error creating milestone(s):', err); res.status(500).json({ error: 'Failed to create milestone(s).' }); @@ -2777,9 +2980,9 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async ( `, [milestoneId]); res.json({ - ...updatedMilestoneRow, - tasks: tasks || [] - }); + ...safeMilestoneRow(updatedMilestoneRow), + tasks: (tasks || []).map(safeTaskRow) +}); } catch (err) { console.error('Error updating milestone:', err); res.status(500).json({ error: 'Failed to update milestone.' }); @@ -2811,13 +3014,13 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => tasksByMilestone = taskRows.reduce((acc, t) => { if (!acc[t.milestone_id]) acc[t.milestone_id] = []; - acc[t.milestone_id].push(t); + acc[t.milestone_id].push(safeTaskRow(t)); return acc; }, {}); } - const uniMils = universalRows.map(m => ({ - ...m, + const uniMils = universalRows.map(m => ({ + ...safeMilestoneRow(m), tasks: tasksByMilestone[m.id] || [] })); return res.json({ milestones: uniMils }); @@ -2843,13 +3046,13 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) => tasksByMilestone = taskRows.reduce((acc, t) => { if (!acc[t.milestone_id]) acc[t.milestone_id] = []; - acc[t.milestone_id].push(t); + acc[t.milestone_id].push(safeTaskRow(t)); return acc; }, {}); } const milestonesWithTasks = milestones.map(m => ({ - ...m, + ...safeMilestoneRow(m), tasks: tasksByMilestone[m.id] || [] })); res.json({ milestones: milestonesWithTasks }); @@ -3113,11 +3316,24 @@ app.delete('/api/premium/milestones/:milestoneId', authenticatePremiumUser, asyn app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => { try { const [rows] = await pool.query( - 'SELECT * FROM financial_profiles WHERE user_id=? LIMIT 1', + `SELECT + current_salary, + additional_income, + monthly_expenses, + monthly_debt_payments, + retirement_savings, + emergency_fund, + retirement_contribution, + emergency_contribution, + extra_cash_emergency_pct, + extra_cash_retirement_pct + FROM financial_profiles + WHERE user_id=? LIMIT 1`, [req.id] ); if (!rows.length) { + // minimal, id-free default payload return res.json({ current_salary: 0, additional_income: 0, @@ -3131,7 +3347,20 @@ app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, r extra_cash_retirement_pct: 50 }); } - res.json(rows[0]); + const r = rows[0] || {}; + // ensure consistent numeric types; no ids/user_id/timestamps returned + return res.json({ + current_salary: Number(r.current_salary ?? 0), + additional_income: Number(r.additional_income ?? 0), + monthly_expenses: Number(r.monthly_expenses ?? 0), + monthly_debt_payments: Number(r.monthly_debt_payments ?? 0), + retirement_savings: Number(r.retirement_savings ?? 0), + emergency_fund: Number(r.emergency_fund ?? 0), + retirement_contribution: Number(r.retirement_contribution ?? 0), + emergency_contribution: Number(r.emergency_contribution ?? 0), + extra_cash_emergency_pct: Number(r.extra_cash_emergency_pct ?? 50), + extra_cash_retirement_pct: Number(r.extra_cash_retirement_pct ?? 50) + }); } catch (err) { console.error('financial‑profile GET error:', err); res.status(500).json({ error: 'DB error' }); @@ -3369,17 +3598,20 @@ app.post('/api/premium/college-profile', authenticatePremiumUser, async (req, re app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res) => { const { careerProfileId } = req.query; try { - const [rows] = await pool.query(` - SELECT * - FROM college_profiles - WHERE user_id = ? - AND career_profile_id = ? - ORDER BY created_at DESC - LIMIT 1 - `, [req.id, careerProfileId]); + const [rows] = await pool.query( + `SELECT * + FROM college_profiles + WHERE user_id = ? + AND career_profile_id = ? + ORDER BY created_at DESC + LIMIT 1`, + [req.id, careerProfileId] + ); if (!rows[0]) return res.status(404).json({ error: 'No college profile for this scenario' }); - res.json(rows[0]); + const row = { ...rows[0] }; + delete row.user_id; // 🚫 do not ship user_id + return res.json(row); } catch (error) { console.error('Error fetching college profile:', error); res.status(500).json({ error: 'Failed to fetch college profile.' }); @@ -3387,32 +3619,115 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res }); // GET every college profile for the logged‑in user -app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req,res)=>{ +app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req, res) => { const sql = ` - SELECT cp.*, - DATE_FORMAT(cp.created_at,'%Y-%m-%d') AS created_at, - IFNULL(cpr.scenario_title, cpr.career_name) AS career_title + SELECT + cp.career_profile_id, + IFNULL(cpr.scenario_title, cpr.career_name) AS career_title, + cp.selected_school, + cp.selected_program, + cp.program_type, + DATE_FORMAT(cp.created_at,'%Y-%m-%d %H:%i:%s') AS created_at FROM college_profiles cp - JOIN career_profiles cpr - ON cpr.id = cp.career_profile_id - AND cpr.user_id = cp.user_id + JOIN career_profiles cpr + ON cpr.id = cp.career_profile_id + AND cpr.user_id = 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 decrypted = rows.map(r => { - const row = { ...r }; + const [rows] = await pool.query(sql, [req.id]); + + // Whitelist shape + decrypt selected strings (no ids beyond career_profile_id) + const safe = rows.map(r => { + const out = { ...r }; for (const k of ['career_title', 'selected_school', 'selected_program']) { - const v = row[k]; + const v = out[k]; if (typeof v === 'string' && v.startsWith('gcm:')) { - try { row[k] = decrypt(v); } catch {} // best-effort + try { out[k] = decrypt(v); } catch { /* best-effort */ } } } - return row; + return { + career_profile_id : out.career_profile_id, // needed by roadmap mapping + career_title : out.career_title, + selected_school : out.selected_school, + selected_program : out.selected_program, + program_type : out.program_type, + created_at : out.created_at + }; }); - res.json({ collegeProfiles: decrypted }); + + return res.json({ collegeProfiles: safe }); }); + +app.delete('/api/premium/college-profile/by-fields', authenticatePremiumUser, async (req, res) => { + try { + const { career_title = null, selected_school = null, selected_program = null, created_at = null } = req.body || {}; + if (!selected_school || !selected_program) { + return res.status(400).json({ error: 'selected_school and selected_program are required' }); + } + + // Pull candidates and compare after best-effort decrypt (ids never leave server) + const [rows] = await pool.query( + `SELECT cp.id, + IFNULL(cpr.scenario_title, cpr.career_name) AS career_title, + cp.selected_school, cp.selected_program, + DATE_FORMAT(cp.created_at,'%Y-%m-%d %H:%i:%s') AS created_at + FROM college_profiles cp + JOIN career_profiles cpr ON cpr.id = cp.career_profile_id AND cpr.user_id = cp.user_id + WHERE cp.user_id = ? + ORDER BY cp.created_at DESC + LIMIT 200`, + [req.id] + ); + + const norm = (s) => (s ?? '').toString().trim(); + const want = { + career_title : norm(career_title), + selected_school: norm(selected_school), + selected_program: norm(selected_program), + created_at : norm(created_at) // optional + }; + + let matchId = null; + for (const r of rows) { + 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 {} + } + } + const sameCore = norm(row.selected_school) === want.selected_school && + norm(row.selected_program) === want.selected_program && + (!want.career_title || norm(row.career_title) === want.career_title); + const sameTime = !want.created_at || norm(row.created_at) === want.created_at; + if (sameCore && sameTime) { matchId = row.id; break; } + } + + if (!matchId) return res.status(404).json({ error: 'not_found' }); + + // Cascade delete (reuse your existing logic) + const [mils] = await pool.query( + `SELECT id FROM milestones WHERE user_id=? AND career_profile_id = + (SELECT career_profile_id FROM college_profiles WHERE id=? LIMIT 1)`, + [req.id, matchId] + ); + const milestoneIds = mils.map(m => m.id); + if (milestoneIds.length) { + const q = milestoneIds.map(() => '?').join(','); + await pool.query(`DELETE FROM tasks WHERE milestone_id IN (${q})`, milestoneIds); + await pool.query(`DELETE FROM milestone_impacts WHERE milestone_id IN (${q})`, milestoneIds); + await pool.query(`DELETE FROM milestones WHERE id IN (${q})`, milestoneIds); + } + + await pool.query(`DELETE FROM college_profiles WHERE id=? AND user_id=?`, [matchId, req.id]); + return res.json({ ok: true }); + } catch (e) { + console.error('college-profile/by-fields delete failed:', e); + return res.status(500).json({ error: 'Failed to delete college profile.' }); + } +}); /* ------------------------------------------------------------------ AI-SUGGESTED MILESTONES ------------------------------------------------------------------ */ @@ -3669,7 +3984,6 @@ app.post('/api/premium/tasks', authenticatePremiumUser, async (req, res) => { const newTask = { id: taskId, milestone_id, - user_id: req.id, title, description: description || '', due_date: due_date || null, @@ -3733,11 +4047,11 @@ app.put('/api/premium/tasks/:taskId', authenticatePremiumUser, async (req, res) ]); const [[updatedTask]] = await pool.query(` - SELECT * - FROM tasks - WHERE id = ? - `, [taskId]); - res.json(updatedTask); + SELECT id, milestone_id, title, description, due_date, status, created_at, updated_at + FROM tasks + WHERE id = ? +`, [taskId]); + res.json(updatedTask); } catch (err) { console.error('Error updating task:', err); res.status(500).json({ error: 'Failed to update task.' }); @@ -4505,6 +4819,9 @@ app.post('/api/premium/reminders', authenticatePremiumUser, async (req, res) => } }); +// Debounce map for parallel checkout taps (key = `${userId}:${priceId}`) +const pendingCheckout = new Map(); + app.post('/api/premium/stripe/create-checkout-session', authenticatePremiumUser, async (req, res) => { @@ -4523,15 +4840,34 @@ app.post('/api/premium/stripe/create-checkout-session', const safeSuccess = success_url && isSafeRedirect(success_url) ? success_url : defaultSuccess; const safeCancel = cancel_url && isSafeRedirect(cancel_url) ? cancel_url : defaultCancel; - const session = await stripe.checkout.sessions.create({ - mode : 'subscription', + // 👇 Gate: if already subscribed, send to Billing Portal instead of Checkout + if (await customerHasActiveSub(customerId)) { + const portal = await stripe.billingPortal.sessions.create({ + customer : customerId, + return_url : `${base}/billing?ck=portal` + }); + return res.json({ url: portal.url }); + } + + // Otherwise, first-time subscription → Checkout (race-proof) + const key = `${req.id}:${priceId}`; + if (pendingCheckout.has(key)) { + const sess = await pendingCheckout.get(key); + return res.json({ url: sess.url }); + } + const p = stripe.checkout.sessions.create({ + mode : 'subscription', customer : customerId, line_items : [{ price: priceId, quantity: 1 }], - allow_promotion_codes : true, - success_url : safeSuccess, - cancel_url : safeCancel + allow_promotion_codes : false, + success_url : `${safeSuccess}`, + cancel_url : `${safeCancel}` + }, { + // reduce duplicate creation on rapid retries + idempotencyKey: `sub:${req.id}:${priceId}` }); - + pendingCheckout.set(key, p); + const session = await p.finally(() => pendingCheckout.delete(key)); return res.json({ url: session.url }); } catch (err) { console.error('create-checkout-session failed:', err?.raw?.message || err); @@ -4548,8 +4884,7 @@ app.get('/api/premium/stripe/customer-portal', try { const base = PUBLIC_BASE || `https://${req.headers.host}`; const { return_url } = req.query; - const safeReturn = return_url && isSafeRedirect(return_url) ? return_url : `${base}/billing`; - + const safeReturn = return_url && isSafeRedirect(return_url) ? return_url : `${base}/billing?ck=portal`; const cid = await getOrCreateStripeCustomerId(req); const portal = await stripe.billingPortal.sessions.create({ diff --git a/backend/shared/requireAuth.js b/backend/shared/requireAuth.js index 02d98ed..775ed72 100644 --- a/backend/shared/requireAuth.js +++ b/backend/shared/requireAuth.js @@ -1,42 +1,44 @@ -// shared/auth/requireAuth.js +// backend/shared/requireAuth.js import jwt from 'jsonwebtoken'; import pool from '../config/mysqlPool.js'; -const { - JWT_SECRET, - TOKEN_MAX_AGE_MS, - SESSION_COOKIE_NAME -} = process.env; - -const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled -const COOKIE_NAME = SESSION_COOKIE_NAME || 'aptiva_session'; - -// Fallback cookie parser if cookie-parser middleware isn't present -function readSessionCookie(req) { - // Prefer cookie-parser, if installed - if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME]; - - // Manual parse from header +function readSessionCookie(req, cookieName) { + if (req.cookies && req.cookies[cookieName]) return req.cookies[cookieName]; const raw = req.headers.cookie || ''; for (const part of raw.split(';')) { const [k, ...rest] = part.trim().split('='); - if (k === COOKIE_NAME) return decodeURIComponent(rest.join('=')); + if (k === cookieName) return decodeURIComponent(rest.join('=')); } return null; } +function toMs(v) { + if (v == null) return 0; + const n = typeof v === 'number' ? v : parseInt(String(v), 10); + if (!Number.isFinite(n) || Number.isNaN(n)) return 0; + return n < 1e12 ? n * 1000 : n; // convert seconds to ms if small +} + export async function requireAuth(req, res, next) { try { - // 1) Try Bearer (legacy) then cookie (current) + const JWT_SECRET = process.env.JWT_SECRET; + const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || 'aptiva_session'; + const MAX_AGE = Number(process.env.TOKEN_MAX_AGE_MS || 0); + + if (!JWT_SECRET) { + console.error('[requireAuth] JWT_SECRET missing'); + return res.status(500).json({ error: 'Server misconfig' }); + } + + // 1) Grab token const authz = req.headers.authorization || ''; - let token = - authz.startsWith('Bearer ') - ? authz.slice(7) - : readSessionCookie(req); + const token = authz.startsWith('Bearer ') + ? authz.slice(7) + : readSessionCookie(req, COOKIE_NAME); if (!token) return res.status(401).json({ error: 'Auth required' }); - // 2) Verify JWT + // 2) Verify let payload; try { payload = jwt.verify(token, JWT_SECRET); } catch { return res.status(401).json({ error: 'Invalid or expired token' }); } @@ -44,27 +46,30 @@ export async function requireAuth(req, res, next) { const userId = payload.id; const iatMs = (payload.iat || 0) * 1000; - // 3) Absolute max token age (optional) + // 3) Absolute max token age if (MAX_AGE && Date.now() - iatMs > MAX_AGE) { return res.status(401).json({ error: 'Session expired. Please sign in again.' }); } - // 4) Invalidate tokens issued before last password change - const sql = pool.raw || pool; - const [rows] = await sql.query( - 'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1', - [userId] - ); - const changedAtMs = rows?.[0]?.password_changed_at - ? new Date(rows[0].password_changed_at).getTime() - : 0; + // 4) Password change invalidation + let changedAtMs = 0; + try { + const sql = pool.raw || pool; + const [rows] = await sql.query( + 'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1', + [userId] + ); + changedAtMs = toMs(rows?.[0]?.password_changed_at); + } catch (e) { + console.warn('[requireAuth] password_changed_at check skipped:', e?.message || e); + } if (changedAtMs && iatMs < changedAtMs) { return res.status(401).json({ error: 'Session invalidated. Please sign in again.' }); } req.userId = userId; - next(); + return next(); } catch (e) { console.error('[requireAuth]', e?.message || e); return res.status(500).json({ error: 'Server error' }); diff --git a/backend/tests/regression.mjs b/backend/tests/regression.mjs new file mode 100644 index 0000000..86b78f3 --- /dev/null +++ b/backend/tests/regression.mjs @@ -0,0 +1,142 @@ +// Run: node backend/tests/regression.mjs +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; + +const BASE = process.env.BASE || 'https://dev1.aptivaai.com'; + +// basic helpers +const j = (o)=>JSON.stringify(o); +const rand = () => Math.random().toString(36).slice(2,10); +const email = `qa+${Date.now()}@aptivaai.com`; +const username = `qa_${rand()}`; +const password = `Aa1!${rand()}Z`; + +let cookie = ''; // session cookie set by server1 + +async function req(path, {method='GET', headers={}, body, stream=false}={}) { + const h = { + 'Content-Type': 'application/json', + ...(cookie ? { Cookie: cookie } : {}), + ...headers + }; + const res = await fetch(`${BASE}${path}`, { method, headers: h, body: body ? j(body): undefined }); + if (!stream) { + const txt = await res.text(); + let json; try { json = JSON.parse(txt); } catch { json = txt; } + return { res, json, txt }; + } + return { res }; // caller handles stream via res.body +} + +function captureSetCookie(headers) { + const sc = headers.get('set-cookie'); + if (sc) cookie = sc.split(';')[0]; +} + +(async () => { + console.log('→ register'); + { + const { res, json } = await req('/api/register', { + method: 'POST', + body: { + username, password, + firstname: 'QA', lastname: 'Bot', + email, zipcode: '30024', state: 'GA', area: 'Atlanta', + career_situation: 'planning' + } + }); + assert.equal(res.status, 201, 'register should 201'); + captureSetCookie(res.headers); + assert.ok(cookie, 'session cookie set'); + } + + console.log('→ signin'); + { + const { res, json } = await req('/api/signin', { + method:'POST', + body:{ username, password } + }); + assert.equal(res.status, 200, 'signin should 200'); + captureSetCookie(res.headers); + } + + console.log('→ user profile fetch'); + { + const { res } = await req('/api/user-profile'); + assert.equal(res.status, 200, 'profile fetch 200'); + } + + console.log('→ support thread + SSE stream'); + // create thread + let threadId; + { + const { res, json } = await req('/api/chat/threads', { method:'POST', body:{ title:'QA run' } }); + assert.equal(res.status, 200, 'create thread 200'); + threadId = json.id; assert.ok(threadId, 'thread id'); + } + // start stream + { + const { res } = await req(`/api/chat/threads/${threadId}/stream`, { + method:'POST', + headers: { Accept: 'text/event-stream' }, + body: { prompt:'hello there', pageContext:'CareerExplorer', snapshot:null }, + stream:true + }); + assert.equal(res.ok, true, 'stream start ok'); + const reader = res.body.getReader(); + let got = false; let lines = 0; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + if (value) { + const chunk = new TextDecoder().decode(value); + lines += (chunk.match(/\n/g)||[]).length; + if (chunk.trim()) got = true; + } + if (lines > 5) break; // we saw a few lines; good enough + } + assert.ok(got, 'got streaming content'); + } + + console.log('→ premium: create career_profile + milestone (server3)'); + // create scenario + let career_profile_id; + { + const { res, json } = await req('/api/premium/career-profile', { + method:'POST', + body: { + scenario_title: 'QA Scenario', + career_name: 'Software Developer', + status: 'planned' + } + }); + assert.equal(res.status, 200, 'career-profile upsert 200'); + career_profile_id = json.career_profile_id; + assert.ok(career_profile_id, 'have career_profile_id'); + } + // create milestone + let milestoneId; + { + const { res, json } = await req('/api/premium/milestone', { + method:'POST', + body: { + title: 'QA Milestone', + description: 'Ensure CRUD works', + date: new Date(Date.now()+86400000).toISOString().slice(0,10), + career_profile_id: career_profile_id + } + }); + assert.equal(res.status, 201, 'milestone create 201'); + milestoneId = (Array.isArray(json) ? json[0]?.id : json?.id); + assert.ok(milestoneId, 'milestone id'); + } + // list milestones + { + const { res, json } = await req(`/api/premium/milestones?careerProfileId=${career_profile_id}`); + assert.equal(res.status, 200, 'milestones list 200'); + const found = (json.milestones||[]).some(m => m.id === milestoneId); + assert.ok(found, 'created milestone present'); + } + + console.log('✓ REGRESSION PASSED'); +})().catch(e => { console.error('✖ regression failed:', e?.message || e); process.exit(1); }); diff --git a/docker-compose.yml b/docker-compose.yml index 8eae7e5..88c6646 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -133,6 +133,7 @@ services: KMS_KEY_NAME: ${KMS_KEY_NAME} DEK_PATH: ${DEK_PATH} JWT_SECRET: ${JWT_SECRET} + APTIVA_API_BASE: ${APTIVA_API_BASE} OPENAI_API_KEY: ${OPENAI_API_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} diff --git a/nginx.conf b/nginx.conf index ef2af71..464f9a4 100644 --- a/nginx.conf +++ b/nginx.conf @@ -1,11 +1,14 @@ -events {} +worker_rlimit_nofile 131072; +events { worker_connections 16384; + } http { + keepalive_requests 10000; include /etc/nginx/mime.types; default_type application/octet-stream; resolver 127.0.0.11 ipv6=off; limit_conn_zone $binary_remote_addr zone=perip:10m; - limit_req_zone $binary_remote_addr zone=reqperip:10m rate=10r/s; + limit_req_zone $binary_remote_addr zone=reqperip:10m rate=100r/s; set_real_ip_from 130.211.0.0/22; set_real_ip_from 35.191.0.0/16; real_ip_header X-Forwarded-For; @@ -13,7 +16,8 @@ http { # ───────────── upstreams to Docker services ───────────── upstream backend5000 { server server1:5000; } # auth & free - upstream backend5001 { server server2:5001; } # onet, distance, etc. + upstream backend5001 { server server2:5001; + keepalive 1024;} # onet, distance, etc. upstream backend5002 { server server3:5002; } # premium upstream gitea_backend { server gitea:3000; } # gitea service (shared network) upstream woodpecker_backend { server woodpecker-server:8000; } @@ -33,14 +37,17 @@ http { ######################################################################## server { listen 443 ssl; - http2 on; # modern syntax + http2 on; + http2_max_concurrent_streams 2048; + http2_idle_timeout 90s; + http2_recv_timeout 90s; server_name dev1.aptivaai.com; ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; - # ==== RUNTIME PROTECTIONS (dev test) ==== + # ==== RUNTIME PROTECTIONS ==== server_tokens off; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-Content-Type-Options nosniff always; @@ -63,12 +70,6 @@ http { proxy_buffers 8 16k; proxy_busy_buffers_size 32k; - limit_conn perip 20; # typical users stay << 10 - limit_conn_status 429; # surface as 429 Too Many Requests - - limit_req zone=reqperip burst=20 nodelay; - limit_req_status 429; - if ($request_method !~ ^(GET|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; } if ($host !~* ^(dev1\.aptivaai\.com)$) { return 444; } @@ -89,25 +90,92 @@ http { } # ───── API reverse‑proxy rules ───── - location ^~ /api/onet/ { proxy_pass http://backend5001; } - location ^~ /api/chat/ { proxy_pass http://backend5001; proxy_http_version 1.1; proxy_buffering off; } - location ^~ /api/job-zones { proxy_pass http://backend5001; } - location ^~ /api/salary { proxy_pass http://backend5001; } - location ^~ /api/cip/ { proxy_pass http://backend5001; } + location ^~ /api/onet/ { + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_read_timeout 90s; + proxy_connect_timeout 15s; + proxy_pass http://backend5001; + } + location ^~ /api/chat/ { + limit_conn perip 10; + limit_req zone=reqperip burst=20 nodelay; + proxy_pass http://backend5001; + proxy_http_version 1.1; + proxy_buffering off; + proxy_set_header Connection ""; + } + location ^~ /api/job-zones { + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_read_timeout 90s; + proxy_connect_timeout 15s; + proxy_pass http://backend5001; + } + location ^~ /api/salary { + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_read_timeout 90s; + proxy_connect_timeout 15s; + proxy_pass http://backend5001; + } + location ^~ /api/cip/ { + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_read_timeout 90s; + proxy_connect_timeout 15s; + proxy_pass http://backend5001; + } + location ^~ /api/projections/ { + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_read_timeout 90s; + proxy_connect_timeout 15s; + proxy_pass http://backend5001; + } + location ^~ /api/tuition/ { proxy_pass http://backend5001; } - location ^~ /api/projections/ { proxy_pass http://backend5001; } location ^~ /api/skills/ { proxy_pass http://backend5001; } location ^~ /api/maps/distance { proxy_pass http://backend5001; } location ^~ /api/schools { proxy_pass http://backend5001; } - location ^~ /api/support { proxy_pass http://backend5001; } + location ^~ /api/support { + limit_conn perip 5; + limit_req zone=reqperip burst=10 nodelay; + proxy_pass http://backend5001; + } location ^~ /api/data/ { proxy_pass http://backend5001; } location ^~ /api/careers/ { proxy_pass http://backend5001; } location ^~ /api/programs/ { proxy_pass http://backend5001; } - location ^~ /api/premium/ { proxy_pass http://backend5002; } + location ^~ /api/premium/ { + limit_conn perip 10; + limit_req zone=reqperip burst=20 nodelay; + proxy_pass http://backend5002; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } + location ^~ /api/premium/stripe/webhook { + proxy_pass http://backend5002; + proxy_http_version 1.1; + proxy_set_header Connection ""; + } location ^~ /api/public/ { proxy_pass http://backend5002; } location ^~ /api/ai-risk { proxy_pass http://backend5002; } + location = /api/signin { limit_conn perip 5; + limit_req zone=reqperip burst=10 nodelay; + proxy_pass http://backend5000; } + location = /api/register { limit_conn perip 3; + limit_req zone=reqperip burst=5 nodelay; + proxy_pass http://backend5000; } + location ^~ /api/auth/ { limit_conn perip 5; + limit_req zone=reqperip burst=10 nodelay; + proxy_pass http://backend5000; } + location = /api/user-profile { limit_conn perip 5; + limit_req zone=reqperip burst=10 nodelay; + proxy_pass http://backend5000; } + + # General API (anything not matched above) – rate-limited location ^~ /api/ { proxy_pass http://backend5000; } # shared proxy headers diff --git a/src/App.js b/src/App.js index 47aba32..b86eba6 100644 --- a/src/App.js +++ b/src/App.js @@ -31,6 +31,7 @@ import CollegeProfileForm from './components/CollegeProfileForm.js'; import CareerRoadmap from './components/CareerRoadmap.js'; import Paywall from './components/Paywall.js'; import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js'; +import { isOnboardingInProgress } from './utils/onboardingGuard.js'; import RetirementPlanner from './components/RetirementPlanner.js'; import ResumeRewrite from './components/ResumeRewrite.js'; import LoanRepaymentPage from './components/LoanRepaymentPage.js'; @@ -68,12 +69,16 @@ function App() { const [drawerPane, setDrawerPane] = useState('support'); const [retireProps, setRetireProps] = useState(null); const [supportOpen, setSupportOpen] = useState(false); - const [userEmail, setUserEmail] = useState(''); const [loggingOut, setLoggingOut] = useState(false); const AUTH_HOME = '/signin-landing'; +const prevPathRef = React.useRef(location.pathname); +useEffect(() => { prevPathRef.current = location.pathname; }, [location.pathname]); + +const IN_OB = (p) => p.startsWith('/premium-onboarding'); + /* ------------------------------------------ ChatDrawer – route-aware tool handlers ------------------------------------------ */ @@ -99,6 +104,40 @@ const uiToolHandlers = useMemo(() => { return {}; // every other page exposes no UI tools }, [pageContext]); +// route-change guard: only warn when LEAVING onboarding mid-flow +useEffect(() => { + const wasIn = IN_OB(prevPathRef.current); + const nowIn = IN_OB(location.pathname); + const leavingOnboarding = wasIn && !nowIn; + + if (!leavingOnboarding) return; + // skip if not mid-flow or if final handoff set the suppress flag + if (!isOnboardingInProgress() || sessionStorage.getItem('suppressOnboardingGuard') === '1') return; + + const ok = window.confirm( + "Onboarding is in progress. If you navigate away, your progress may not be saved. Continue?" + ); + if (!ok) { + // bounce back to where the user was + navigate(prevPathRef.current, { replace: true }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps +}, [location.pathname]); + +// browser close/refresh guard: only when currently on onboarding and mid-flow +useEffect(() => { + const onBeforeUnload = (e) => { + if (IN_OB(location.pathname) + && isOnboardingInProgress() + && sessionStorage.getItem('suppressOnboardingGuard') !== '1') { + e.preventDefault(); + e.returnValue = "Onboarding is in progress. If you leave now, your progress may not be saved."; + } + }; + window.addEventListener('beforeunload', onBeforeUnload); + return () => window.removeEventListener('beforeunload', onBeforeUnload); +}, [location.pathname]); + // Retirement bot is only relevant on these pages const canShowRetireBot = pageContext === 'RetirementPlanner' || @@ -151,18 +190,6 @@ const showPremiumCTA = !premiumPaths.some(p => location.pathname.startsWith(p) ); - - // Helper to see if user is mid–premium-onboarding - function isOnboardingInProgress() { - try { - const stored = JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}'); - // If step < 4 (example), user is in progress - return stored.step && stored.step < 4; - } catch (e) { - return false; - } - } - // ============================== // 1) Single Rehydrate UseEffect // ============================== @@ -188,10 +215,15 @@ if (loggingOut) return; (async () => { setIsLoading(true); try { - // axios client already: withCredentials + Bearer from authMemory - const { data } = await api.get('/api/user-profile'); + // Fetch only the minimal fields App needs for nav/landing/support + const { data } = await api.get('/api/user-profile?fields=firstname,is_premium,is_pro_premium'); if (cancelled) return; - setUser(data); + setUser(prev => ({ + ...(prev || {}), + firstname : data?.firstname || '', + is_premium : !!data?.is_premium, + is_pro_premium: !!data?.is_pro_premium, + })); setIsAuthenticated(true); } catch (err) { if (cancelled) return; @@ -218,14 +250,6 @@ if (loggingOut) return; // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.pathname, navigate, loggingOut]); - /* ===================== - Support Modal Email - ===================== */ - useEffect(() => { - setUserEmail(user?.email || ''); - }, [user]); - - // ========================== // 2) Logout Handler + Modal // ========================== @@ -239,14 +263,12 @@ if (loggingOut) return; } }; - - const confirmLogout = async () => { setLoggingOut(true); // 1) Ask the server to clear the session cookie try { // If you created /logout (no /api prefix): - await api.post('api/logout'); // axios client is withCredentials: true + await api.post('/api/logout', {}); // axios client is withCredentials: true // If your route is /api/signout instead, use: // await api.post('/api/signout'); } catch (e) { @@ -296,8 +318,6 @@ const cancelLogout = () => { ); } - - // ===================== // Main Render / Layout // ===================== @@ -558,7 +578,6 @@ const cancelLogout = () => { setSupportOpen(false)} - userEmail={userEmail} /> {/* LOGOUT BUTTON */} diff --git a/src/components/BillingResult.js b/src/components/BillingResult.js index 29a26c8..9d328b7 100644 --- a/src/components/BillingResult.js +++ b/src/components/BillingResult.js @@ -1,45 +1,47 @@ -import { useEffect, useState, useContext } from 'react'; +import { useEffect, useState } from 'react'; import { useLocation, Link } from 'react-router-dom'; import { Button } from './ui/button.js'; -import { ProfileCtx } from '../App.js'; import api from '../auth/apiClient.js'; export default function BillingResult() { - const { setUser } = useContext(ProfileCtx) || {}; - const q = new URLSearchParams(useLocation().search); - const outcome = q.get('ck'); // 'success' | 'cancel' | null - const [loading, setLoading] = useState(true); + const q = new URLSearchParams(useLocation().search); + const outcome = q.get('ck'); // 'success' | 'cancel' | 'portal' | null - /* ───────────────────────────────────────────────────────── - 1) Ask the API for the latest user profile (flags, etc.) - cookies + in-mem token handled by apiClient - ───────────────────────────────────────────────────────── */ + const [loading, setLoading] = useState(true); + const [flags, setFlags] = useState({ is_premium: false, is_pro_premium: false }); + + // ───────────────────────────────────────────────────────── + // 1) Always fetch the latest subscription flags from backend + // ───────────────────────────────────────────────────────── useEffect(() => { - let cancelled = false; + let cancelled = false; (async () => { try { - const { data } = await api.get('/api/user-profile'); - if (!cancelled && data && setUser) setUser(data); + const { data } = await api.get('/api/premium/subscription/status'); + if (!cancelled && data) { + setFlags({ + is_premium: !!data.is_premium, + is_pro_premium: !!data.is_pro_premium, + }); + } } catch (err) { - // Non-fatal here; UI still shows outcome - console.warn('[BillingResult] failed to refresh profile', err?.response?.status || err?.message); + console.warn('[BillingResult] failed to refresh flags', err?.message); } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; - }, [setUser]); + }, []); - /* ───────────────────────────────────────────────────────── - 2) UX while waiting for that round‑trip - ───────────────────────────────────────────────────────── */ if (loading) { return

Checking your subscription…

; } - /* ───────────────────────────────────────────────────────── - 3) Success – Stripe completed the checkout flow - ───────────────────────────────────────────────────────── */ + const hasPremium = flags.is_premium || flags.is_pro_premium; + + // ───────────────────────────────────────────────────────── + // 2) Success (Checkout completed) + // ───────────────────────────────────────────────────────── if (outcome === 'success') { return (
@@ -47,29 +49,59 @@ export default function BillingResult() {

Premium features have been unlocked on your account.

- -
); } - /* ───────────────────────────────────────────────────────── - 4) Cancelled – user backed out of Stripe - ───────────────────────────────────────────────────────── */ + // ───────────────────────────────────────────────────────── + // 3) Portal return + // ───────────────────────────────────────────────────────── + if (outcome === 'portal') { + return ( +
+

Billing updated

+

+ {hasPremium ? 'Your subscription is active.' : 'No active subscription on your account.'} +

+ + {!hasPremium && ( + + )} +
+ ); + } + + // ───────────────────────────────────────────────────────── + // 4) Cancelled checkout or direct visit + // ───────────────────────────────────────────────────────── return (
-

Subscription cancelled

-

No changes were made to your account.

- +

+ {hasPremium ? 'Subscription active' : 'No subscription changes'} +

+

+ {hasPremium + ? 'You still have premium access.' + : 'No active subscription on your account.'} +

+ {!hasPremium && ( + + )}
); } diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 7b42dfc..dfde47f 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -1,3 +1,4 @@ +// src/components/CareerExplorer.js import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import ChatCtx from '../contexts/ChatCtx.js'; @@ -8,10 +9,8 @@ import CareerModal from './CareerModal.js'; import InterestMeaningModal from './InterestMeaningModal.js'; import CareerSearch from './CareerSearch.js'; import { Button } from './ui/button.js'; -import apiFetch from '../auth/apiFetch.js'; import api from '../auth/apiClient.js'; - const STATES = [ { name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' }, { name: 'Arkansas', code: 'AR' }, { name: 'California', code: 'CA' }, { name: 'Colorado', code: 'CO' }, @@ -32,24 +31,18 @@ const STATES = [ { name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' }, ]; - - -// -------------- CIP HELPER FUNCTIONS -------------- - - -// 1) Insert leading zero if there's only 1 digit before the decimal -function ensureTwoDigitsBeforeDecimal(cipStr) { - // e.g. "4.0201" => "04.0201" - return cipStr.replace(/^(\d)\./, '0$1.'); -} - -// 2) Clean an array of CIP codes, e.g. ["4.0201", "14.0901"] => ["0402", "1409"] -function cleanCipCodes(cipArray) { - return cipArray.map((code) => { - let codeStr = code.toString(); - codeStr = ensureTwoDigitsBeforeDecimal(codeStr); // ensure "04.0201" - return codeStr.replace('.', '').slice(0, 4); // => "040201" => "0402" - }); +/* ----------------------------------------------------------- + * Minimal profile field fetcher + * - Back-end can honor ?fields= (preferred). + * - If not, we still only *use* the requested keys here. + * --------------------------------------------------------- */ +async function fetchProfileFields(fieldNames = []) { + const qs = fieldNames.length ? `?fields=${encodeURIComponent(fieldNames.join(','))}` : ''; + const res = await api.get(`/api/user-profile${qs}`); + const all = res.data || {}; + const out = {}; + fieldNames.forEach((k) => { if (k in all) out[k] = all[k]; }); + return out; } function getFullStateName(code) { @@ -57,219 +50,17 @@ function getFullStateName(code) { return found ? found.name : ''; } -function CareerExplorer() { - const navigate = useNavigate(); - const location = useLocation(); - - // ---------- Component States ---------- - const [userProfile, setUserProfile] = useState(null); - const [careerList, setCareerList] = useState([]); - const [careerDetails, setCareerDetails] = useState(null); - const [showModal, setShowModal] = useState(false); - const [userState, setUserState] = useState(null); - const [areaTitle, setAreaTitle] = useState(null); - const [userZipcode, setUserZipcode] = useState(null); - const [error, setError] = useState(null); - const [pendingCareerForModal, setPendingCareerForModal] = useState(null); - - const { setChatSnapshot } = useContext(ChatCtx); - - const [showInterestMeaningModal, setShowInterestMeaningModal] = useState(false); - const [modalData, setModalData] = useState({ - career: null, - askForInterest: false, - defaultInterest: 3, - defaultMeaning: 3, +// -------------- CIP HELPERS -------------- +function ensureTwoDigitsBeforeDecimal(cipStr) { + return cipStr.replace(/^(\d)\./, '0$1.'); +} +function cleanCipCodes(cipArray) { + return cipArray.map((code) => { + let codeStr = code.toString(); + codeStr = ensureTwoDigitsBeforeDecimal(codeStr); + return codeStr.replace('.', '').slice(0, 4); }); - - - const fitRatingMap = { - Best: 5, - Great: 4, - Good: 3, - }; - // This is where we'll hold ALL final suggestions (with job_zone merged) - const [careerSuggestions, setCareerSuggestions] = useState([]); - - const [salaryData, setSalaryData] = useState([]); - const [economicProjections, setEconomicProjections] = useState(null); - const [selectedJobZone, setSelectedJobZone] = useState(''); - const [selectedFit, setSelectedFit] = useState(''); - const [selectedCareer, setSelectedCareer] = useState(null); - const [loading, setLoading] = useState(false); - const [progress, setProgress] = useState(0); - - - // Weighted "match score" logic. (unchanged) - const priorityWeight = (priority, response) => { - const weightMap = { - interests: { - 'I know my interests (completed inventory)': 5, - 'I’m not sure yet': 1, - }, - meaning: { - 'Yes, very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - stability: { - 'Very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - growth: { - 'Yes, very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - balance: { - 'Yes, very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - recognition: { - 'Very important': 5, - 'Somewhat important': 3, - 'Not as important': 1, - }, - }; - return weightMap[priority][response] || 1; - }; - - const jobZoneLabels = { - '1': 'Little or No Preparation', - '2': 'Some Preparation Needed', - '3': 'Medium Preparation Needed', - '4': 'Considerable Preparation Needed', - '5': 'Extensive Preparation Needed', - }; - - const fitLabels = { - Best: 'Best - Very Strong Match', - Great: 'Great - Strong Match', - Good: 'Good - Less Strong Match', - }; - - // -------------------------------------------------- - // fetchSuggestions - combined suggestions + job zone - // -------------------------------------------------- - const fetchSuggestions = async (answers, profileData) => { - if (!answers) { - setCareerSuggestions([]); - localStorage.removeItem('careerSuggestionsCache'); - - // Reset loading & progress if userProfile has no answers - setLoading(true); - setProgress(0); - setLoading(false); - return; - } - - try { - setLoading(true); - setProgress(0); - - // 1) O*NET answers -> initial career list - const submitRes = await api.post('/api/onet/submit_answers', { - answers, - state: profileData.state, - area: profileData.area, - }); - const { careers = [] } = submitRes.data || {}; - const flattened = careers.flat(); - - // We'll do an extra single call for job zones + 4 calls for each career: - // => total steps = 1 (jobZones) + (flattened.length * 4) - let totalSteps = 1 + (flattened.length * 4); - let completedSteps = 0; - - // Increments the global progress bar - const increment = () => { - completedSteps++; - const pct = Math.round((completedSteps / totalSteps) * 100); - setProgress(pct); - }; - - // A helper that does a GET request, increments progress on success/fail - const fetchWithProgress = async (url, params) => { - try { - const res = await api.get(url, { params }); - increment(); - return res.data; - } catch (err) { - increment(); - return {}; // or null - } - }; - - // 2) job zones (one call for all SOC codes) - const socCodes = flattened.map((c) => c.code); - const zonesRes = await api.post('/api/job-zones', { socCodes }).catch(() => null); - // increment progress for this single request - increment(); - - const jobZoneData = zonesRes?.data || {}; - - // 3) For each career, also fetch CIP, job details, projections, salary - const enrichedPromiseArray = flattened.map(async (career) => { - const strippedSoc = career.code.split('.')[0]; - - // build URLs - const cipUrl = `/api/cip/${career.code}`; - const jobDetailsUrl = `/api/onet/career-description/${career.code}`; - const economicUrl = `/api/projections/${strippedSoc}`; - const salaryParams = { socCode: strippedSoc, area: profileData.area }; - - // We'll fetch them in parallel with our custom fetchWithProgress: - const [cipRaw, jobRaw, ecoRaw, salRaw] = await Promise.all([ - fetchWithProgress(cipUrl), - fetchWithProgress(jobDetailsUrl), - fetchWithProgress(economicUrl), - fetchWithProgress('/api/salary', salaryParams), - ]); - - // parse data - const cip = cipRaw || {}; - const jobDetails = jobRaw || {}; - const economic = ecoRaw || {}; - const salary = salRaw || {}; - - // Check if data is missing - const isCipMissing = !cip || Object.keys(cip).length === 0; - const isJobDetailsMissing = !jobDetails || Object.keys(jobDetails).length === 0; - const isEconomicMissing = - !economic || Object.values(economic).every((val) => val === 'N/A' || val === '*'); - const isSalaryMissing = !salary || Object.keys(salary).length === 0; - - const isLimitedData = - isCipMissing || isJobDetailsMissing || isEconomicMissing || isSalaryMissing; - - return { - ...career, - job_zone: jobZoneData[strippedSoc]?.job_zone || null, - limitedData: isLimitedData, - }; - }); - - // Wait for everything to finish - const finalEnrichedCareers = await Promise.all(enrichedPromiseArray); - - // Store final suggestions in local storage - localStorage.setItem('careerSuggestionsCache', JSON.stringify(finalEnrichedCareers)); - - // Update React state - setCareerSuggestions(finalEnrichedCareers); - - } catch (err) { - console.error('[fetchSuggestions] Error:', err); - setCareerSuggestions([]); - localStorage.removeItem('careerSuggestionsCache'); - } finally { - // Hide spinner - setLoading(false); - } -}; - +} const stripSoc = (s = '') => s.split('.')[0]; async function fetchCareerMetaBySoc(soc) { @@ -277,362 +68,391 @@ async function fetchCareerMetaBySoc(soc) { if (!base) return null; try { const { data } = await api.get('/api/careers/by-soc', { params: { soc: base } }); - return data || null; // { soc_code, title, cip_codes, ratings } + return data || null; } catch { return null; } } - // -------------------------------------- - // On mount, load suggestions from cache - // -------------------------------------- +function CareerExplorer() { + const navigate = useNavigate(); + const location = useLocation(); + const { setChatSnapshot } = useContext(ChatCtx); + + // ---------- Component States ---------- + const [careerList, setCareerList] = useState([]); + const [careerDetails, setCareerDetails] = useState(null); + const [showModal, setShowModal] = useState(false); + + // Only the tiny location/answers slice we actually need on this page + const [userState, setUserState] = useState(null); + const [areaTitle, setAreaTitle] = useState(null); + // ZIP is *not* needed unless we navigate; EducationalPrograms will fetch it itself + const [interestAnswers, setInterestAnswers] = useState(null); + const [prioritiesJson, setPrioritiesJson] = useState(null); + const [riasecJson, setRiasecJson] = useState(null); + + const [error, setError] = useState(null); + const [pendingCareerForModal, setPendingCareerForModal] = useState(null); + + const [careerSuggestions, setCareerSuggestions] = useState([]); + const [salaryData, setSalaryData] = useState([]); + const [economicProjections, setEconomicProjections] = useState(null); + const [selectedJobZone, setSelectedJobZone] = useState(''); + const [selectedFit, setSelectedFit] = useState(''); + const [selectedCareer, setSelectedCareer] = useState(null); + const [loading, setLoading] = useState(false); + const [progress, setProgress] = useState(0); + const [showInterestMeaningModal, setShowInterestMeaningModal] = useState(false); + + const fitRatingMap = { Best: 5, Great: 4, Good: 3 }; + const jobZoneLabels = { + '1': 'Little or No Preparation', + '2': 'Some Preparation Needed', + '3': 'Medium Preparation Needed', + '4': 'Considerable Preparation Needed', + '5': 'Extensive Preparation Needed', + }; + const fitLabels = { + Best: 'Best - Very Strong Match', + Great: 'Great - Strong Match', + Good: 'Good - Less Strong Match', + }; + + // ---------- Load cache on mount (no profile call) ---------- useEffect(() => { const cached = localStorage.getItem('careerSuggestionsCache'); if (cached) { - const parsed = JSON.parse(cached); - if (parsed?.length) { - setCareerSuggestions(parsed); + try { + const parsed = JSON.parse(cached); + if (Array.isArray(parsed) && parsed.length) setCareerSuggestions(parsed); + } catch {} + } + (async () => { + try { + const { career_list } = await fetchProfileFields(['career_list']); + if (career_list) { + try { setCareerList(JSON.parse(career_list)); } catch { /* ignore bad JSON */ } + } + } catch (e) { + // non-fatal; matrix will be empty until user adds } + })(); + }, []); + + // ---------- Gate priorities modal with minimal fetch ---------- + useEffect(() => { + (async () => { + try { + const { career_priorities, interest_inventory_answers } = + await fetchProfileFields(['career_priorities','interest_inventory_answers']); + setPrioritiesJson(career_priorities || null); + setInterestAnswers(interest_inventory_answers || null); + if (!career_priorities) setShowModal(true); + } catch (e) { + console.error('priorities check failed', e); + // if we can’t verify, default to showing the modal to capture them + setShowModal(true); + } + })(); + }, []); + + // ---------- Reload suggestions (minimal: answers + location) ---------- + const fetchSuggestions = useCallback(async (answers, profileCore) => { + if (!answers) { + setCareerSuggestions([]); + localStorage.removeItem('careerSuggestionsCache'); + setLoading(true); setProgress(0); setLoading(false); + return; + } + + try { + setLoading(true); + setProgress(0); + + // 1) submit answers + const submitRes = await api.post('/api/onet/submit_answers', { + answers, + state: profileCore.state, + area: profileCore.area, + }); + const flattened = (submitRes.data?.careers || []).flat(); + + let totalSteps = 1 + (flattened.length * 4); + let completedSteps = 0; + const setProgressThrottled = (next) => { + setProgress((prev) => (next - prev >= 3 ? next : prev)); + }; + const increment = () => { + completedSteps++; + const pct = Math.round((completedSteps / totalSteps) * 100); + setProgress((prev) => (pct - prev >= 3 ? pct : prev)); + }; + const fetchWithProgress = async (url, params) => { + try { const r = await api.get(url, { params }); increment(); return r.data; } + catch { increment(); return {}; } + }; + + // 2) job zones (bulk) + const socCodes = flattened.map((c) => c.code); + const zonesRes = await api.post('/api/job-zones', { socCodes }).catch(() => null); + increment(); + const jobZoneData = zonesRes?.data || {}; + + // 3) enrich each career + // 3) enrich each career — cap client concurrency so the browser doesn't abort (HTTP 499) + const CONCURRENCY = 36; // safe for Chrome/Edge HTTP/2 stream limits + async function enrichOne(career) { + const strippedSoc = career.code.split('.')[0]; + const [cipRaw, jobRaw, ecoRaw, salRaw] = await Promise.all([ + fetchWithProgress(`/api/cip/${career.code}`), + fetchWithProgress(`/api/onet/career-description/${career.code}`), + fetchWithProgress(`/api/projections/${strippedSoc}`), + fetchWithProgress('/api/salary', { socCode: strippedSoc, area: profileCore.area }), + ]); + + const cip = cipRaw || {}; + const jobDetails = jobRaw || {}; + const economic = ecoRaw || {}; + const salary = salRaw || {}; + + const isCipMissing = !cip || Object.keys(cip).length === 0; + const isJobMissing = !jobDetails || Object.keys(jobDetails).length === 0; + const isEcoMissing = !economic || Object.values(economic).every(v => v === 'N/A' || v === '*'); + const isSalMissing = !salary || Object.keys(salary).length === 0; + + return { + ...career, + job_zone: jobZoneData[strippedSoc]?.job_zone || null, + limitedData: (isCipMissing || isJobMissing || isEcoMissing || isSalMissing), + }; + } + + // Smooth N-worker pool consumes the queue; prevents bursty “waves” + const enriched = new Array(flattened.length); + let cursor = 0; + async function runWorker() { + while (true) { + const i = cursor++; + if (i >= flattened.length) break; + enriched[i] = await enrichOne(flattened[i]); + } + } + await Promise.all(Array.from({ length: CONCURRENCY }, runWorker)); + + localStorage.setItem('careerSuggestionsCache', JSON.stringify(enriched)); + setCareerSuggestions(enriched); + } catch (err) { + console.error('[fetchSuggestions] Error:', err); + setCareerSuggestions([]); + localStorage.removeItem('careerSuggestionsCache'); + } finally { + setLoading(false); } }, []); - // -------------------------------------- - // Load user profile - // -------------------------------------- - useEffect(() => { - setLoading(true); - - const fetchUserProfile = async () => { - try { - const res = await api.get('/api/user-profile'); - - if (res.status === 200) { - const profileData = res.data; - setUserProfile(profileData); - setUserState(profileData.state); - setAreaTitle(profileData.area); - setUserZipcode(profileData.zipcode); - - if (profileData.career_list) { - setCareerList(JSON.parse(profileData.career_list)); - } - - if (!profileData.career_priorities) { - setShowModal(true); - } - -} else { - setShowModal(true); -} - } catch (err) { - console.error('Error fetching user profile:', err); - setShowModal(true); - } finally { - setLoading(false); + // ---------- if coming back from Interest Inventory ---------- + useEffect(() => { + let cancelled = false; + (async () => { + if (!location.state?.fromInterestInventory) return; + const core = await fetchProfileFields(['interest_inventory_answers','state','area']); + if (cancelled) return; + setInterestAnswers(core.interest_inventory_answers || null); + setUserState(core.state || null); + setAreaTitle(core.area || null); + if (core.interest_inventory_answers) { + await fetchSuggestions(core.interest_inventory_answers, { state: core.state, area: core.area }); } + if (!cancelled) { + // clear nav state AFTER suggestions land to avoid a re-render spike + navigate('.', { replace: true }); + } + })(); + return () => { cancelled = true; }; + }, [location.state, fetchSuggestions, navigate]); + + // ---------- career PRIORITIES / RIASEC for chat snapshot ---------- + const priorities = useMemo(() => { + try { return prioritiesJson ? JSON.parse(prioritiesJson) : {}; } + catch { return {}; } + }, [prioritiesJson]); + + const coreCtx = useMemo(() => { + let riasecScores = null; + try { riasecScores = riasecJson ? JSON.parse(riasecJson) : null; } catch {} + + const weight = (p, resp) => { + const m = { + interests: { 'I know my interests (completed inventory)': 5, 'I’m not sure yet': 1 }, + meaning : { 'Yes, very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + stability: { 'Very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + growth : { 'Yes, very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + balance : { 'Yes, very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + recognition: { 'Very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + }; + return (m[p] && m[p][resp]) || 1; }; - fetchUserProfile(); - }, []); + const pw = priorities ? { + stability : weight('stability' , priorities.stability) / 5, + growth : weight('growth' , priorities.growth) / 5, + balance : weight('balance' , priorities.balance) / 5, + recognition : weight('recognition', priorities.recognition)/ 5, + interests : weight('interests' , priorities.interests) / 5, + mission : weight('meaning' , priorities.meaning) / 5, + } : null; + + return { riasecScores, priorityWeights: pw }; + }, [priorities, riasecJson]); - // ------------------------------------------------------ - // If user came from Interest Inventory => auto-fetch - // ------------------------------------------------------ useEffect(() => { - if ( - location.state?.fromInterestInventory && - userProfile?.interest_inventory_answers - ) { - fetchSuggestions(userProfile.interest_inventory_answers, userProfile); - // remove that state so refresh doesn't re-fetch - navigate('.', { replace: true }); - } - }, [location.state, userProfile, fetchSuggestions, navigate]); + setChatSnapshot({ coreCtx, modalCtx: null }); + }, [coreCtx, setChatSnapshot]); - // ------------------------------------------------------ -// handleCareerClick – fetch & build data for one career -// ------------------------------------------------------ -const handleCareerClick = useCallback( - async (career) => { + // ---------- career modal open flow ---------- + const handleCareerClick = useCallback(async (career) => { const socCode = career.code; if (!socCode) return; - /* Reset modal state & show spinner */ + // ensure we have location for salary/projections + if (!userState || !areaTitle) { + try { + const core = await fetchProfileFields(['state','area']); + setUserState(core.state || null); + setAreaTitle(core.area || null); + } catch {} + } + setSelectedCareer(career); setCareerDetails(null); setLoading(true); try { - /* ---------- 1. CIP lookup ---------- */ let cipCode = null; - try { - const { data } = await api.get(`/api/cip/${socCode}`); - cipCode = data?.cipCode ?? null; - } catch { /* swallow */ } - - /* ---------- 2. Job description & tasks ---------- */ - let description = ''; - let tasks = []; + try { const { data } = await api.get(`/api/cip/${socCode}`); cipCode = data?.cipCode ?? null; } catch {} + let description = '', tasks = []; try { const { data: jd } = await api.get(`/api/onet/career-description/${socCode}`); - description = jd?.description ?? ''; - tasks = jd?.tasks ?? []; - } catch { /* swallow */ } + description = jd?.description ?? ''; tasks = jd?.tasks ?? []; + } catch {} - /* ---------- 3. Salary data ---------- */ - const salaryRes = await api - .get('/api/salary', { - params: { socCode: socCode.split('.')[0], area: areaTitle }, - }) - .catch(() => ({ data: {} })); + const salaryRes = await api.get('/api/salary', { + params: { socCode: socCode.split('.')[0], area: areaTitle || '' }, + }).catch(() => ({ data: {} })); - const s = salaryRes.data; + const s = salaryRes.data || {}; const salaryData = s && Object.keys(s).length ? [ - { percentile: '10th Percentile', regionalSalary: +s.regional?.regional_PCT10 || 0, nationalSalary: +s.national?.national_PCT10 || 0 }, - { percentile: '25th Percentile', regionalSalary: +s.regional?.regional_PCT25 || 0, nationalSalary: +s.national?.national_PCT25 || 0 }, - { percentile: 'Median', regionalSalary: +s.regional?.regional_MEDIAN || 0, nationalSalary: +s.national?.national_MEDIAN || 0 }, - { percentile: '75th Percentile', regionalSalary: +s.regional?.regional_PCT75 || 0, nationalSalary: +s.national?.national_PCT75 || 0 }, - { percentile: '90th Percentile', regionalSalary: +s.regional?.regional_PCT90 || 0, nationalSalary: +s.national?.national_PCT90 || 0 }, - ] + { percentile: '10th Percentile', regionalSalary: +s.regional?.regional_PCT10 || 0, nationalSalary: +s.national?.national_PCT10 || 0 }, + { percentile: '25th Percentile', regionalSalary: +s.regional?.regional_PCT25 || 0, nationalSalary: +s.national?.national_PCT25 || 0 }, + { percentile: 'Median', regionalSalary: +s.regional?.regional_MEDIAN || 0, nationalSalary: +s.national?.national_MEDIAN || 0 }, + { percentile: '75th Percentile', regionalSalary: +s.regional?.regional_PCT75 || 0, nationalSalary: +s.national?.national_PCT75 || 0 }, + { percentile: '90th Percentile', regionalSalary: +s.regional?.regional_PCT90 || 0, nationalSalary: +s.national?.national_PCT90 || 0 }, + ] : []; - /* ---------- 4. Economic projections ---------- */ const fullStateName = getFullStateName(userState); - const projRes = await api - .get(`/api/projections/${socCode.split('.')[0]}`, { - params: { state: fullStateName }, - }) - .catch(() => ({ data: {} })); + const projRes = await api.get(`/api/projections/${socCode.split('.')[0]}`, { + params: { state: fullStateName || '' }, + }).catch(() => ({ data: {} })); - /* ---------- 5. Do we have *anything* useful? ---------- */ const haveSalary = salaryData.length > 0; - const haveProj = !!(projRes.data.state || projRes.data.national); - const haveJobInfo = description || tasks.length > 0; + const haveProj = !!(projRes.data?.state || projRes.data?.national); + const haveJobInfo = !!(description || (tasks && tasks.length)); - if (!haveSalary && !haveProj && !haveJobInfo) { - setCareerDetails({ - error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, - }); - return; - } - - /* ---------- 6. AI‑risk (only if job‑info exists) ---------- */ let aiRisk = null; if (haveJobInfo) { - try { - aiRisk = (await api.get(`/api/ai-risk/${socCode}`)).data; - } catch (err) { + try { aiRisk = (await api.get(`/api/ai-risk/${socCode}`)).data; } + catch (err) { if (err.response?.status === 404) { try { - const aiRes = await api.post('/api/public/ai-risk-analysis', { - socCode, - careerName: career.title, - jobDescription: description, - tasks, - }); + const aiRes = await api.post('/api/public/ai-risk-analysis', { socCode, careerName: career.title, jobDescription: description, tasks }); aiRisk = aiRes.data; - // cache for next time (best‑effort) api.post('/api/ai-risk', aiRisk).catch(() => {}); - } catch { /* GPT fallback failed – ignore */ } + } catch {} } } } - /* ---------- 7. Build & show modal ---------- */ + if (!haveSalary && !haveProj && !haveJobInfo) { + setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.` }); + return; + } + setCareerDetails({ ...career, - cipCode, // may be null – downstream handles it + cipCode, jobDescription: description, tasks, salaryData, economicProjections: projRes.data ?? {}, - aiRisk, // can be null + aiRisk, }); } catch (fatal) { console.error('[handleCareerClick] fatal:', fatal); - setCareerDetails({ - error: `We're sorry, but detailed info for "${career.title}" isn't available right now.`, - }); + setCareerDetails({ error: `We're sorry, but detailed info for "${career.title}" isn't available right now.` }); } finally { setLoading(false); } - }, - [areaTitle, userState] -); + }, [areaTitle, userState]); - // ------------------------------------------------------ - // handleCareerFromSearch - // ------------------------------------------------------ - const handleCareerFromSearch = useCallback( - (obj) => { - const adapted = { - code: obj.soc_code, - title: obj.title, - cipCode: obj.cip_code, - fromManualSearch: true - }; - console.log('[Dashboard -> handleCareerFromSearch] adapted =>', adapted); - handleCareerClick(adapted); - }, - [handleCareerClick] - ); + // ---------- add-from-search ---------- + const handleCareerFromSearch = useCallback((obj) => { + const adapted = { code: obj.soc_code, title: obj.title, cipCode: obj.cip_code, fromManualSearch: true }; + setLoading(true); + setPendingCareerForModal(adapted); + }, []); - // ------------------------------------------------------ - // pendingCareerForModal effect - // ------------------------------------------------------ useEffect(() => { if (pendingCareerForModal) { - console.log('[useEffect] pendingCareerForModal =>', pendingCareerForModal); - handleCareerFromSearch(pendingCareerForModal); + handleCareerClick(pendingCareerForModal); setPendingCareerForModal(null); } - }, [pendingCareerForModal, handleCareerFromSearch]); + }, [pendingCareerForModal, handleCareerClick]); + // ---------- chat snapshot currently ignores modalCtx on this page ---------- + // (kept as null above) - // ------------------------------------------------------ - // Derived data / Helpers - // ------------------------------------------------------ - const priorities = useMemo(() => { - return userProfile?.career_priorities - ? JSON.parse(userProfile.career_priorities) - : {}; - }, [userProfile]); - - const priorityKeys = [ - 'interests', - 'meaning', - 'stability', - 'growth', - 'balance', - 'recognition', - ]; - - - /* ---------- core context: sent every turn ---------- */ -const coreCtx = useMemo(() => { - // 1) Riasec scores - const riasecScores = userProfile?.riasec_scores - ? JSON.parse(userProfile.riasec_scores) - : null; - - // 2) priority weights normalised 0-1 - const priorityWeights = priorities ? { - stability : priorityWeight('stability' , priorities.stability) / 5, - growth : priorityWeight('growth' , priorities.growth) / 5, - balance : priorityWeight('balance' , priorities.balance) / 5, - recognition : priorityWeight('recognition', priorities.recognition)/ 5, - interests : priorityWeight('interests' , priorities.interests) / 5, - mission : priorityWeight('meaning' , priorities.meaning) / 5, - } : null; - - return { riasecScores, priorityWeights }; -}, [userProfile, priorities]); - -/* ---------- modal context: exists only while a modal is open ---------- */ -const modalCtx = useMemo(() => { - if (!selectedCareer || !careerDetails) return null; - - const medianRow = careerDetails.salaryData - ?.find(r => r.percentile === "Median"); - - return { - socCode : selectedCareer.code, - title : selectedCareer.title, - aiRisk : careerDetails.aiRisk?.riskLevel ?? "n/a", - salary : medianRow - ? { regional : medianRow.regionalSalary, - national : medianRow.nationalSalary } - : null, - projections : careerDetails.economicProjections ?? {}, - description : careerDetails.jobDescription, - tasks : careerDetails.tasks, - }; -}, [selectedCareer, careerDetails]); - - -useEffect(() => { - // send null when no modal is open → ChatDrawer simply omits it - setChatSnapshot({ coreCtx, modalCtx }); -}, [coreCtx, modalCtx, setChatSnapshot]); - - // ------------------------------------------------------ - // Save comparison list to backend - // ------------------------------------------------------ + // ---------- Save comparison list (only career_list) ---------- const saveCareerListToBackend = async (newCareerList) => { try { - await api.post( - '/api/user-profile', - { - firstName: userProfile?.firstname, - lastName: userProfile?.lastname, - email: userProfile?.email, - zipCode: userProfile?.zipcode, - state: userProfile?.state, - area: userProfile?.area, - careerSituation: userProfile?.career_situation, - interest_inventory_answers: userProfile?.interest_inventory_answers, - career_priorities: userProfile?.career_priorities, - career_list: JSON.stringify(newCareerList), - }, - ); - } catch (err) { + await api.post('/api/user-profile', { career_list: JSON.stringify(newCareerList) }); + } catch (err) { console.error('Error saving career_list:', err); } }; - - // ------------------------------------------------------ - // Add/Remove from comparison - // ------------------------------------------------------ const addCareerToList = async (career) => { - // 1) get default (pre-calculated) ratings from backend by SOC - const meta = await fetchCareerMetaBySoc(career.code); - const masterRatings = meta?.ratings || {}; + const meta = await fetchCareerMetaBySoc(career.code); + const masterRatings = meta?.ratings || {}; - // 2) figure out interest - const userHasInventory = - !career.fromManualSearch && - priorities.interests && - priorities.interests !== "I’m not sure yet"; + const userHasInventory = !career.fromManualSearch && !!priorities.interests && priorities.interests !== 'I’m not sure yet'; + const defaultInterestValue = userHasInventory ? (fitRatingMap[career.fit] || masterRatings.interests || 3) : 3; + const defaultMeaningValue = 3; - const defaultInterestValue = userHasInventory - ? (fitRatingMap[career.fit] || masterRatings.interests || 3) - : 3; + setModalData({ + career, + masterRatings, + askForInterest: !userHasInventory, + defaultInterest: defaultInterestValue, + defaultMeaning: defaultMeaningValue, + }); + setShowInterestMeaningModal(true); + }; - const defaultMeaningValue = 3; + const [modalData, setModalData] = useState({ career: null, askForInterest: false, defaultInterest: 3, defaultMeaning: 3 }); - // 3) open the InterestMeaningModal (unchanged) - setModalData({ - career, - masterRatings, - askForInterest: !userHasInventory, - defaultInterest: defaultInterestValue, - defaultMeaning: defaultMeaningValue, - }); - setShowInterestMeaningModal(true); -}; - - - const handleModalSave = ({ interest, meaning }) => { + const handleModalSave = ({ interest, meaning }) => { const { career, masterRatings, askForInterest, defaultInterest } = modalData; if (!career) return; - // If we asked for interest, use the user's input; otherwise keep the default - const finalInterest = askForInterest && interest !== null - ? interest - : defaultInterest; + const finalInterest = askForInterest && interest !== null ? interest : defaultInterest; + const finalMeaning = meaning; - const finalMeaning = meaning; - - - const stabilityRating = - career.ratings && career.ratings.stability !== undefined - ? career.ratings.stability - : masterRatings.stability || 3; - - const growthRating = masterRatings.growth || 3; - const balanceRating = masterRatings.balance || 3; + const stabilityRating = (career.ratings && career.ratings.stability !== undefined) ? career.ratings.stability : (masterRatings.stability || 3); + const growthRating = masterRatings.growth || 3; + const balanceRating = masterRatings.balance || 3; const recognitionRating = masterRatings.recognition || 3; const careerWithUserRatings = { @@ -647,173 +467,77 @@ useEffect(() => { }, }; - setCareerList((prevList) => { - if (prevList.some((c) => c.code === career.code)) { - alert("Career already in comparison list."); - return prevList; + setCareerList((prev) => { + if (prev.some((c) => c.code === career.code)) { + alert('Career already in comparison list.'); + return prev; } - const newList = [...prevList, careerWithUserRatings]; + const newList = [...prev, careerWithUserRatings]; saveCareerListToBackend(newList); return newList; }); }; - const removeCareerFromList = (careerCode) => { - setCareerList((prevList) => { - const newList = prevList.filter((c) => c.code !== careerCode); + const removeCareerFromList = (code) => { + setCareerList((prev) => { + const newList = prev.filter((c) => c.code !== code); saveCareerListToBackend(newList); return newList; }); }; + // ---------- “Reload Career Suggestions” ---------- + const handleReloadSuggestions = useCallback(async () => { + try { + // fetch only the minimal fields this flow needs + const core = await fetchProfileFields(['interest_inventory_answers','state','area']); + setInterestAnswers(core.interest_inventory_answers || null); + setUserState(core.state || null); + setAreaTitle(core.area || null); - // ------------------------------------------------------ - // "Select for Education" => navigate with CIP codes - // ------------------------------------------------------ -const handleSelectForEducation = async (career) => { - if (!career) return; - - const ok = window.confirm( - `Are you sure you want to move on to Educational Programs for “${career.title}”?` - ); - if (!ok) return; - - const fullSoc = career.soc_code || career.code || ''; - const baseSoc = stripSoc(fullSoc); - if (!baseSoc) { - alert('Sorry – this career is missing a valid SOC code.'); - return; - } - - // 1) try local JSON by base SOC (tolerates .00 vs none) - let rawCips = []; -try { - const meta = await fetchCareerMetaBySoc(baseSoc); - rawCips = Array.isArray(meta?.cip_codes) ? meta.cip_codes : []; -} catch { /* best-effort */ } - -let cleanedCips = cleanCipCodes(rawCips); - -// 2) fallback: ask server2 to map SOC→CIP if none were found -if (!cleanedCips.length) { - try { - const candidates = [ - fullSoc, // as-is - baseSoc, // stripped - fullSoc.includes('.') ? null : `${fullSoc}.00` // add .00 if missing - ].filter(Boolean); - - let fromApi = null; - for (const soc of candidates) { - try { - const { data } = await api.get(`/api/cip/${soc}`); - if (data?.cipCode) { fromApi = data.cipCode; break; } - } catch {} + if (!core.interest_inventory_answers) { + alert('Please complete the Interest Inventory to get suggestions.'); + return; + } + await fetchSuggestions(core.interest_inventory_answers, { state: core.state, area: core.area }); + } catch (e) { + console.error('reload suggestions failed', e); } - if (fromApi) { - rawCips = [fromApi]; - cleanedCips = cleanCipCodes(rawCips); - } - } catch { /* ignore */ } -} + }, [fetchSuggestions]); - // Persist for the next page (keep raw list if we have it) - const careerForStorage = { - ...career, - soc_code : fullSoc, - cip_code : rawCips - }; - localStorage.setItem('selectedCareer', JSON.stringify(careerForStorage)); + // ---------- event bridge ---------- + useEffect(() => { + const onAdd = (e) => { + const { socCode, careerName } = e.detail || {}; + if (!socCode) return; + let career = careerSuggestions.find((c) => c.code === socCode); + if (!career) career = { code: socCode, title: careerName || '(name unavailable)', fit: 'Good' }; + addCareerToList(career); + }; + const onOpen = (e) => { + const { socCode } = e.detail || {}; + if (!socCode) return; + const career = careerSuggestions.find((c) => c.code === socCode); + if (career) handleCareerClick(career); + }; + window.addEventListener('add-career', onAdd); + window.addEventListener('open-career', onOpen); + return () => { + window.removeEventListener('add-career', onAdd); + window.removeEventListener('open-career', onOpen); + }; + }, [careerSuggestions, addCareerToList, handleCareerClick]); - // Navigate with the robust base SOC + cleaned CIP prefixes - navigate('/educational-programs', { - state: { - socCode : baseSoc, - cipCodes : cleanedCips, // can be [] if absolutely nothing found - careerTitle : career.title, - userZip : userZipcode, - userState : userState - } - }); -}; + // ---------- UI ---------- + const prioritiesKeys = ['interests','meaning','stability','growth','balance','recognition']; - // ------------------------------------------------------ - // Filter logic for jobZone, Fit - // ------------------------------------------------------ - const filteredCareers = useMemo(() => { - return careerSuggestions.filter((career) => { - // If user selected a jobZone, check if career.job_zone matches - const jobZoneMatches = selectedJobZone - ? career.job_zone !== null && - career.job_zone !== undefined && - Number(career.job_zone) === Number(selectedJobZone) - : true; - - // If user selected a fit, check if career.fit matches - const fitMatches = selectedFit ? career.fit === selectedFit : true; - - return jobZoneMatches && fitMatches; - }); - }, [careerSuggestions, selectedJobZone, selectedFit]); - - - - useEffect(() => { - /* ---------- add-to-comparison ---------- */ - const onAdd = (e) => { - console.log('[onAdd] detail →', e.detail); - const { socCode, careerName } = e.detail || {}; - if (!socCode) { - console.warn('[add-career] missing socCode – aborting'); - return; - } - - // 1. see if the career is already in the filtered list - let career = filteredCareers.find((c) => c.code === socCode); - - // 2. if not, make a stub so the list can still save - if (!career) { - career = { - code : socCode, - title: careerName || '(name unavailable)', - fit : 'Good', - }; - } - - // 3. push it into the comparison table - addCareerToList(career); - }; - - /* ---------- open-modal ---------- */ - const onOpen = (e) => { - const { socCode } = e.detail || {}; - if (!socCode) return; - const career = filteredCareers.find((c) => c.code === socCode); - if (career) handleCareerClick(career); - }; - - window.addEventListener('add-career', onAdd); - window.addEventListener('open-career', onOpen); - return () => { - window.removeEventListener('add-career', onAdd); - window.removeEventListener('open-career', onOpen); - }; -}, [filteredCareers, addCareerToList, handleCareerClick]); - - - // ------------------------------------------------------ - // Loading Overlay - // ------------------------------------------------------ const renderLoadingOverlay = () => { if (!loading) return null; return (
-
+

{progress}% — Loading Career Suggestions... @@ -823,25 +547,35 @@ if (!cleanedCips.length) { ); }; - - // ------------------------------------------------------ - // Render - // ------------------------------------------------------ + // ---- Render return (

{renderLoadingOverlay()} {showModal && ( setShowModal(false)} + /* provide just what the modal needs to prefill */ + userProfile={{ + career_priorities: prioritiesJson || null, + interest_inventory_answers: interestAnswers || null, + }} + onClose={async (didSave) => { + setShowModal(false); + // if it saved, re-fetch minimal fields so weights/suggestions are correct + if (didSave) { + try { + const { career_priorities, interest_inventory_answers } = + await fetchProfileFields(['career_priorities','interest_inventory_answers']); + setPrioritiesJson(career_priorities || null); + setInterestAnswers(interest_inventory_answers || null); + } catch {} + } + }} /> )}
-

- Explore Careers - use these tools to find your best fit -

+

Explore Careers - use these tools to find your best fit

{ @@ -851,26 +585,20 @@ if (!cleanedCips.length) { />
-
-

Career Comparison

- {/* quick-edit link */} -
+ {careerList.length ? ( - {priorityKeys.map((priority) => ( - + {prioritiesKeys.map((k) => ( + ))} @@ -879,53 +607,40 @@ if (!cleanedCips.length) { {careerList.map((career) => { const ratings = career.ratings || {}; - const interestsRating = ratings.interests || 3; - const meaningRating = ratings.meaning || 3; - const stabilityRating = ratings.stability || 3; - const growthRating = ratings.growth || 3; - const balanceRating = ratings.balance || 3; + const interestsRating = ratings.interests || 3; + const meaningRating = ratings.meaning || 3; + const stabilityRating = ratings.stability || 3; + const growthRating = ratings.growth || 3; + const balanceRating = ratings.balance || 3; const recognitionRating = ratings.recognition || 3; - const userInterestsWeight = priorityWeight( - 'interests', - priorities.interests || 'I’m not sure yet' - ); - const userMeaningWeight = priorityWeight( - 'meaning', - priorities.meaning - ); - const userStabilityWeight = priorityWeight( - 'stability', - priorities.stability - ); - const userGrowthWeight = priorityWeight( - 'growth', - priorities.growth - ); - const userBalanceWeight = priorityWeight( - 'balance', - priorities.balance - ); - const userRecognitionWeight = priorityWeight( - 'recognition', - priorities.recognition - ); + const weight = (p, resp) => { + const m = { + interests: { 'I know my interests (completed inventory)': 5, 'I’m not sure yet': 1 }, + meaning : { 'Yes, very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + stability: { 'Very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + growth : { 'Yes, very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + balance : { 'Yes, very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + recognition: { 'Very important': 5, 'Somewhat important': 3, 'Not as important': 1 }, + }; + return (m[p] && m[p][resp]) || 1; + }; const totalWeight = - userInterestsWeight + - userMeaningWeight + - userStabilityWeight + - userGrowthWeight + - userBalanceWeight + - userRecognitionWeight; + weight('interests', priorities.interests || 'I’m not sure yet') + + weight('meaning' , priorities.meaning) + + weight('stability', priorities.stability) + + weight('growth' , priorities.growth) + + weight('balance' , priorities.balance) + + weight('recognition', priorities.recognition); const weightedScore = - interestsRating * userInterestsWeight + - meaningRating * userMeaningWeight + - stabilityRating * userStabilityWeight + - growthRating * userGrowthWeight + - balanceRating * userBalanceWeight + - recognitionRating * userRecognitionWeight; + interestsRating * weight('interests', priorities.interests || 'I’m not sure yet') + + meaningRating * weight('meaning' , priorities.meaning) + + stabilityRating * weight('stability', priorities.stability) + + growthRating * weight('growth' , priorities.growth) + + balanceRating * weight('balance' , priorities.balance) + + recognitionRating * weight('recognition', priorities.recognition); const matchScore = (weightedScore / (totalWeight * 5)) * 100; @@ -938,24 +653,51 @@ if (!cleanedCips.length) { - + @@ -964,131 +706,67 @@ if (!cleanedCips.length) { })}
Career - {priority} - {k}Match Actions
{growthRating} {balanceRating} {recognitionRating} - {matchScore.toFixed(1)}% - {matchScore.toFixed(1)}% - - -
- ) : ( -

No careers added to comparison.

- )} + ) : (

No careers added to comparison.

)}
- + - + - + - {/* Legend container with less internal gap, plus a left margin */} -
- ⚠️ - = May have limited data for this career path -
-
+
+ ⚠️ + = May have limited data for this career path +
+
- - {/* Now we pass the *filteredCareers* into the CareerSuggestions component */} { - setSelectedCareer(career); - handleCareerClick(career); - }} - /> + careerSuggestions={careerSuggestions} + onCareerClick={(career) => { setSelectedCareer(career); handleCareerClick(career); }} + /> setShowInterestMeaningModal(false)} - onSave={handleModalSave} - careerTitle={modalData.career?.title || ""} - askForInterest={modalData.askForInterest} - defaultInterest={modalData.defaultInterest} - defaultMeaning={modalData.defaultMeaning} - /> + show={showInterestMeaningModal} // same modal container, shown when priorities missing + onClose={() => setShowInterestMeaningModal(false)} + onSave={handleModalSave} + careerTitle={modalData.career?.title || ''} + askForInterest={modalData.askForInterest} + defaultInterest={modalData.defaultInterest} + defaultMeaning={modalData.defaultMeaning} + /> {selectedCareer && ( { - setSelectedCareer(null); - setCareerDetails(null); - }} + closeModal={() => { setSelectedCareer(null); setCareerDetails(null); }} addCareerToList={addCareerToList} /> )}
- This page includes information from  - - O*NET OnLine - -  by the U.S. Department of Labor, Employment & Training Administration - (USDOL/ETA). Used under the  - - CC BY 4.0 license - - . **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are - enriched with resources from the  - - Bureau of Labor Statistics - -  and program information from the  - - National Center for Education Statistics - - . -
- + This page includes information from  + O*NET OnLine +  by the U.S. Department of Labor, Employment & Training Administration (USDOL/ETA). Used under the  + CC BY 4.0 license. + **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are enriched with resources from the  + Bureau of Labor Statistics +  and program information from the  + National Center for Education Statistics. +
); } diff --git a/src/components/CareerProfileList.js b/src/components/CareerProfileList.js index f8da03f..86b5115 100644 --- a/src/components/CareerProfileList.js +++ b/src/components/CareerProfileList.js @@ -22,11 +22,17 @@ const nav = useNavigate(); })(); }, []); - async function remove(id) { + async function remove(row) { if (!window.confirm('Delete this career profile?')) return; try { - const r = await apiFetch(`/api/premium/career-profile/${id}`, { + const r = await apiFetch(`/api/premium/career-profile/by-fields`, { method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + scenario_title: row.scenario_title || null, + career_name : row.career_name || null, + start_date : row.start_date || null + }) }); if (!r.ok) { // 401/403 will already be handled by apiFetch @@ -34,7 +40,13 @@ const nav = useNavigate(); alert(msg || 'Failed to delete'); return; } - setRows(prev => prev.filter(row => row.id !== id)); + setRows(prev => prev.filter(x => + !( + (x.scenario_title || '') === (row.scenario_title || '') && + (x.career_name || '') === (row.career_name || '') && + (x.start_date || '') === (row.start_date || '') + ) + )); } catch (e) { console.error('Delete failed:', e); alert('Failed to delete'); @@ -69,13 +81,11 @@ const nav = useNavigate(); {r.start_date} - edit - + >edit +
+
)} diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index 51003fb..fe85d3c 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -345,7 +345,6 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) { // Basic states const [userProfile, setUserProfile] = useState(null); const [financialProfile, setFinancialProfile] = useState(null); - const [masterCareerRatings, setMasterCareerRatings] = useState([]); const [existingCareerProfiles, setExistingCareerProfiles] = useState([]); const [selectedCareer, setSelectedCareer] = useState(initialCareer || null); const [careerProfileId, setCareerProfileId] = useState(null); @@ -527,6 +526,7 @@ useEffect(() => { if (location.state?.fromOnboarding) { modalGuard.current.skip = true; // suppress once window.history.replaceState({}, '', location.pathname); + sessionStorage.removeItem('suppressOnboardingGuard'); } }, [location.state, location.pathname]); @@ -535,7 +535,7 @@ useEffect(() => { * ------------------------------------------------------------*/ useEffect(() => { (async () => { - const up = await authFetch('/api/user-profile'); + const up = await authFetch('/api/user-profile?fields=area,state'); if (up.ok && (up.headers.get('content-type')||'').includes('application/json')) { setUserProfile(await up.json()); } @@ -691,20 +691,51 @@ useEffect(() => { } }, [recommendations]); - - // 2) load JSON => masterCareerRatings - useEffect(() => { - (async () => { - try { - const { data } = await api.get('/api/data/careers-with-ratings'); - setMasterCareerRatings(data || []); - } catch (err) { - console.error('Error loading career ratings via API =>', err); - setMasterCareerRatings([]); + // ───────────────────────────────────────────────────────────── + // Resolve SOC without shipping any bulk JSON to the browser + // Order: scenarioRow.soc_code → selectedCareer.code → resolve by title + // ───────────────────────────────────────────────────────────── + const resolveSoc = useCallback(async () => { + // 1) scenarioRow already has it? + const fromScenario = scenarioRow?.soc_code || scenarioRow?.socCode; + if (fromScenario) { + setFullSocCode(fromScenario); + setStrippedSocCode(stripSocCode(fromScenario)); + return; } - })(); -}, []); - + // 2) selectedCareer from onboarding/search + const fromSelected = selectedCareer?.code || selectedCareer?.soc_code || selectedCareer?.socCode; + if (fromSelected) { + setFullSocCode(fromSelected); + setStrippedSocCode(stripSocCode(fromSelected)); + return; + } + // 3) Fallback: resolve via tiny title→SOC endpoint (no bulk dataset) + const title = (scenarioRow?.career_name || '').trim(); + if (!title) { + setFullSocCode(null); + setStrippedSocCode(null); + return; + } + try { + const r = await authFetch(`/api/careers/resolve?title=${encodeURIComponent(title)}`); + if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) { + const j = await r.json(); // { title, soc_code, ... } + if (j?.soc_code) { + setFullSocCode(j.soc_code); + setStrippedSocCode(stripSocCode(j.soc_code)); + return; + } + } + // not found + setFullSocCode(null); + setStrippedSocCode(null); + } catch (e) { + console.error('SOC resolve failed', e); + setFullSocCode(null); + setStrippedSocCode(null); + } + }, [scenarioRow, selectedCareer]); // 3) fetch user’s career-profiles // utilities you already have in this file @@ -889,27 +920,6 @@ const refetchScenario = useCallback(async () => { if (r.ok) setScenarioRow(await r.json()); }, [careerProfileId]); - // 5) from scenarioRow => find the full SOC => strip - useEffect(() => { - if (!scenarioRow?.career_name || !masterCareerRatings.length) { - setStrippedSocCode(null); - setFullSocCode(null); - return; - } - const target = normalizeTitle(scenarioRow.career_name); - const found = masterCareerRatings.find( - (obj) => normalizeTitle(obj.title || '') === target - ); - if (!found) { - console.warn('No matching SOC =>', scenarioRow.career_name); - setStrippedSocCode(null); - setFullSocCode(null); - return; - } - setStrippedSocCode(stripSocCode(found.soc_code)); - setFullSocCode(found.soc_code); - }, [scenarioRow, masterCareerRatings]); - useEffect(() => { if (!fullSocCode || !scenarioRow || scenarioRow.riskLevel) return; (async () => { @@ -931,6 +941,8 @@ const refetchScenario = useCallback(async () => { })(); }, [fullSocCode, scenarioRow]); +useEffect(() => { resolveSoc(); }, [resolveSoc]); + async function fetchAiRisk(socCode, careerName, description, tasks) { let aiRisk = null; diff --git a/src/components/CollegeProfileList.js b/src/components/CollegeProfileList.js index 4d85080..b08078c 100644 --- a/src/components/CollegeProfileList.js +++ b/src/components/CollegeProfileList.js @@ -51,14 +51,37 @@ export default function CollegeProfileList() { }, []); /* ───────── delete helper ───────── */ - async function handleDelete(id) { + async function handleDelete(row) { if (!window.confirm("Delete this college plan?")) return; try { - const res = await authFetch(`/api/premium/college-profile/${id}`, { + const res = await authFetch(`/api/premium/college-profile/by-fields`, { method: "DELETE", + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + career_title : row.career_title || null, + selected_school: row.selected_school || null, + selected_program: row.selected_program || null, + created_at : row.created_at || null // optional disambiguator + }) }); + if (!res.ok) throw new Error(`delete failed → ${res.status}`); + setRows((r) => r.filter(x => + !( + (x.career_title||'') === (row.career_title||'') && + (x.selected_school||'') === (row.selected_school||'') && + (x.selected_program||'')=== (row.selected_program||'') && + (x.created_at||'') === (row.created_at||'') + ) + )); if (!res.ok) throw new Error(`delete failed → ${res.status}`); - setRows((r) => r.filter((row) => row.id !== id)); + setRows((r) => r.filter(x => + !( + (x.career_title||'') === (row.career_title||'') && + (x.selected_school||'') === (row.selected_school||'') && + (x.selected_program||'')=== (row.selected_program||'') && + (x.created_at||'') === (row.created_at||'') + ) + )); } catch (err) { console.error("Delete failed:", err); alert("Could not delete – see console."); @@ -88,8 +111,10 @@ export default function CollegeProfileList() { loading={loadingCareers} authFetch={authFetch} onChange={(careerObj) => { - if (!careerObj?.id) return; - navigate(`/profile/college/${careerObj.id}/new`); + if (!careerObj) return; + const title = careerObj.scenario_title || careerObj.career_name || ''; + const start = careerObj.start_date || ''; + navigate(`/profile/college/new?career=${encodeURIComponent(title)}&start=${encodeURIComponent(start)}`); }} />
@@ -118,20 +143,18 @@ export default function CollegeProfileList() { {rows.map((r) => ( - + {r.career_title} {r.selected_school} {r.selected_program} {r.created_at?.slice(0, 10)} - edit - + >edit
@@ -467,6 +540,7 @@ const ready = name="is_in_state" checked={is_in_state} onChange={handleParentFieldChange} + onBlur={()=>saveDraft({collegeData:{is_in_state}}).catch(()=>{})} className="h-4 w-4" /> @@ -478,6 +552,7 @@ const ready = name="is_online" checked={is_online} onChange={handleParentFieldChange} + onBlur={()=>saveDraft({collegeData:{is_online}}).catch(()=>{})} className="h-4 w-4" /> @@ -489,6 +564,7 @@ const ready = name="loan_deferral_until_graduation" checked={loan_deferral_until_graduation} onChange={handleParentFieldChange} + onBlur={()=>saveDraft({collegeData:{loan_deferral_until_graduation}}).catch(()=>{})} className="h-4 w-4" /> @@ -590,6 +666,7 @@ const ready = value={credit_hours_required} onChange={handleParentFieldChange} placeholder="e.g. 120" + onBlur={(e)=>saveDraft({collegeData:{credit_hours_required: parseFloat(e.target.value)||0}}).catch(()=>{})} className="w-full border rounded p-2" /> @@ -603,6 +680,7 @@ const ready = value={credit_hours_per_year} onChange={handleParentFieldChange} placeholder="e.g. 24" + onBlur={(e)=>saveDraft({collegeData:{credit_hours_per_year: parseFloat(e.target.value)||0}}).catch(()=>{})} className="w-full border rounded p-2" /> @@ -629,6 +707,7 @@ const ready = value={annual_financial_aid} onChange={handleParentFieldChange} placeholder="e.g. 2000" + onBlur={(e)=>saveDraft({collegeData:{annual_financial_aid: parseFloat(e.target.value)||0}}).catch(()=>{})} className="w-full border rounded p-2" /> - {!userEmail && ( -

- You must be signed in to contact support. -

- )} + {/* Informational note — no PII fetched/rendered */} +

+ We’ll reply to the email associated with your account. +

-
- - -
-
Cancel -
diff --git a/src/components/UserProfile.js b/src/components/UserProfile.js index 4a5885a..9a2a5c7 100644 --- a/src/components/UserProfile.js +++ b/src/components/UserProfile.js @@ -77,7 +77,14 @@ function UserProfile() { useEffect(() => { (async () => { try { - const res = await authFetch('/api/user-profile', { method: 'GET' }); + const res = await authFetch('/api/user-profile?fields=' + + [ + 'firstname','lastname','email', + 'zipcode','state','area','career_situation', + 'phone_e164','sms_opt_in' + ].join(','), + { method: 'GET' } + ); if (!res || !res.ok) return; const data = await res.json(); diff --git a/src/utils/onboardingDraftApi.js b/src/utils/onboardingDraftApi.js index fed365e..1ad57fd 100644 --- a/src/utils/onboardingDraftApi.js +++ b/src/utils/onboardingDraftApi.js @@ -1,33 +1,37 @@ - import authFetch from './authFetch.js'; +// utils/onboardingDraftApi.js +import authFetch from './authFetch.js'; - const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, ''); - const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`; + // Always same-origin so the session cookie goes with it + const DRAFT_URL = '/api/premium/onboarding/draft'; - export async function loadDraft() { - const res = await authFetch(DRAFT_URL); - if (!res) return null; // session expired - if (res.status === 404) return null; - if (!res.ok) throw new Error(`loadDraft ${res.status}`); - return res.json(); // null or { id, step, data } - } +export async function loadDraft() { + const res = await authFetch(DRAFT_URL); + if (!res) return null; // session expired + if (res.status === 404) return null; + if (!res.ok) throw new Error(`loadDraft ${res.status}`); + return res.json(); // null or { id, step, data } +} /** * saveDraft(input) * Accepts either: * - { id, step, data } // full envelope * - { id, step, careerData?, financialData?, collegeData? } // partial sections - * Merges partials with the current server draft to avoid clobbering. + * Merges partials with the current server draft. Never POSTs an empty data blob. */ export async function saveDraft(input = {}) { - // Normalize inputs let { id = null, step = 0, data } = input; - // If caller passed sections (careerData / financialData / collegeData) instead of a 'data' envelope, - // merge them into the existing server draft so we don't drop other sections. + // Treat {} like "no data provided" so we hit the merge/guard path below. + const isEmptyObject = + data && typeof data === 'object' && !Array.isArray(data) && Object.keys(data).length === 0; + if (isEmptyObject) data = null; + if (data == null) { - // Load existing draft (may be null/404 for first-time) + // Merge any section patches with existing draft; skip POST if literally nothing to save. let existing = null; - try { existing = await loadDraft(); } catch (_) {} + try { existing = await loadDraft(); } catch {} + const existingData = (existing && existing.data) || {}; const has = (k) => Object.prototype.hasOwnProperty.call(input, k); @@ -36,12 +40,32 @@ export async function saveDraft(input = {}) { if (has('financialData')) patch.financialData = input.financialData; if (has('collegeData')) patch.collegeData = input.collegeData; + const hasPatch = + Object.prototype.hasOwnProperty.call(patch, 'careerData') || + Object.prototype.hasOwnProperty.call(patch, 'financialData') || + Object.prototype.hasOwnProperty.call(patch, 'collegeData'); + + // If no patch and no existing draft, there is nothing to persist → NO POST. + if (!hasPatch && !existing) { + return { id: null, step: step ?? 0 }; + } + + // If no patch but we do have an existing draft, only bump step locally (no clobber). + if (!hasPatch && existing) { + return { id: existing.id, step: step ?? existing.step ?? 0 }; + } + + // Normal case: merge existing + patch data = { ...existingData, ...patch }; - // Prefer caller's id/step when provided; otherwise reuse existing if (id == null && existing?.id != null) id = existing.id; if (step == null && existing?.step != null) step = existing.step; } + // Final guard: if data is still empty, do not POST. + if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) { + return { id: id || null, step: step ?? 0 }; + } + const res = await authFetch(DRAFT_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -52,9 +76,9 @@ export async function saveDraft(input = {}) { return res.json(); // { id, step } } - export async function clearDraft() { - const res = await authFetch(DRAFT_URL, { method: 'DELETE' }); - if (!res) return false; - if (!res.ok) throw new Error(`clearDraft ${res.status}`); - return true; // server returns { ok: true } - } +export async function clearDraft() { + const res = await authFetch(DRAFT_URL, { method: 'DELETE' }); + if (!res) return false; + if (!res.ok) throw new Error(`clearDraft ${res.status}`); + return true; +} diff --git a/tests/smoke.sh b/tests/smoke.sh new file mode 100644 index 0000000..9392dc8 --- /dev/null +++ b/tests/smoke.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +BASE="${BASE:-https://dev1.aptivaai.com}" +GOOD_ORIGIN="${GOOD_ORIGIN:-https://dev1.aptivaai.com}" +BAD_ORIGIN="${BAD_ORIGIN:-https://evil.example.com}" + +pass(){ echo "✅ $*"; } +fail(){ echo "❌ $*"; exit 1; } + +# --- Health checks (server1/2/3) --- +for p in /livez /readyz /healthz; do + curl -fsS "$BASE$ p" >/dev/null || fail "server2 $p" +done +pass "server2 health endpoints up" + +# try server1 + server3 via Nginx locations if you expose them (adjust paths if prefixed) +for SVC in server1 server3; do + curl -fsS "$BASE/$SVC/healthz" >/dev/null && pass "$SVC healthz OK" || echo "ℹ️ $SVC /healthz not routed publicly (ok if intentional)" +done + +# --- CORS: allowed origin (expect 200 for a safe GET) --- +code=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $GOOD_ORIGIN" "$BASE/api/data/career-clusters") +[[ "$code" == "200" ]] || fail "CORS allowed origin should be 200, got $code" +pass "CORS allowed origin OK" + +# --- CORS: bad origin (expect 403) --- +code=$(curl -s -o /dev/null -w '%{http_code}' -H "Origin: $BAD_ORIGIN" "$BASE/api/data/career-clusters") +[[ "$code" == "403" ]] || fail "CORS bad origin should be 403, got $code" +pass "CORS bad origin blocked" + +# --- Public data flows (server2) --- +curl -fsS "$BASE/api/projections/15-1252?state=GA" | jq . > /dev/null || fail "projections" +curl -fsS "$BASE/api/salary?socCode=15-1252&area=Atlanta-Sandy Springs-Roswell, GA" | jq . > /dev/null || fail "salary" +curl -fsS "$BASE/api/tuition?cipCodes=1101,1103&state=GA" | jq . > /dev/null || fail "tuition" +pass "public data endpoints OK" + +echo "✓ SMOKE PASSED"