Network tab hides, Reload Career Suggestion performance

This commit is contained in:
Josh 2025-09-05 16:18:33 +00:00
parent 89a3ef3f60
commit 1b73144c06
26 changed files with 2293 additions and 1533 deletions

View File

@ -1 +1 @@
fb83dd6424562765662889aea6436fdb4b1b975f-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b c8af44caf3dec8c5f306fef35c4925be044f0374-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -787,78 +787,41 @@ const signinLimiter = rateLimit({ windowMs: 15*60*1000, max: 50, standardHeaders
app.post('/api/signin', signinLimiter, async (req, res) => { app.post('/api/signin', signinLimiter, async (req, res) => {
const { username, password } = req.body; const { username, password } = req.body;
if (!username || !password) { if (!username || !password) {
return res return res.status(400).json({ error: 'Both username and password are required' });
.status(400)
.json({ error: 'Both username and password are required' });
} }
// SELECT only the columns you actually have: // Only fetch what you need to verify creds
// '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.
const query = ` const query = `
SELECT SELECT ua.user_id AS userProfileId, ua.hashed_password
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
FROM user_auth ua FROM user_auth ua
LEFT JOIN user_profile up ON ua.user_id = up.id
WHERE ua.username = ? WHERE ua.username = ?
LIMIT 1
`; `;
try { try {
const [results] = await pool.query(query, [username]); const [results] = await pool.query(query, [username]);
if (!results || results.length === 0) { if (!results || results.length === 0) {
return res.status(401).json({ error: 'Invalid username or password' }); return res.status(401).json({ error: 'Invalid username or password' });
} }
const row = results[0]; const { userProfileId, hashed_password } = results[0];
const isMatch = await bcrypt.compare(password, hashed_password);
// Compare password with bcrypt
const isMatch = await bcrypt.compare(password, row.hashed_password);
if (!isMatch) { if (!isMatch) {
return res.status(401).json({ error: 'Invalid username or password' }); return res.status(401).json({ error: 'Invalid username or password' });
} }
// Return user info + token // Cookie-based session only; do NOT return id/token/user in body
// 'authId' is user_auth's PK, but typically you won't need it on the client const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' });
// 'row.userProfileId' is the actual user_profile.id res.cookie(COOKIE_NAME, token, sessionCookieOptions());
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 {}
}
return res.status(200).json({ message: 'Login successful' });
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
});
} catch (err) { } catch (err) {
console.error('Error querying user_auth:', err.message); console.error('Error querying user_auth:', err.message);
return res return res.status(500).json({ error: 'Failed to query user authentication data' });
.status(500)
.json({ error: 'Failed to query user authentication data' });
} }
}); });
app.post('/api/logout', (_req, res) => { app.post('/api/logout', (_req, res) => {
res.clearCookie(COOKIE_NAME, sessionCookieOptions()); res.clearCookie(COOKIE_NAME, sessionCookieOptions());
return res.status(200).json({ ok: true }); 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) => { app.get('/api/user-profile', requireAuth, async (req, res) => {
const profileId = req.userId; // from requireAuth middleware const profileId = req.userId;
try { try {
const [results] = await pool.query('SELECT * FROM user_profile WHERE id = ?', [profileId]); // Optional minimal-field mode: /api/user-profile?fields=a,b,c
if (!results || results.length === 0) { const raw = (req.query.fields || '').toString().trim();
return res.status(404).json({ error: 'User profile not found' });
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 row = results[0];
if (row?.email) { 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 {} try { row.email = decrypt(row.email); } catch {}
} }
res.status(200).json(row); // 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);
}
// 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) { } catch (err) {
console.error('Error fetching user profile:', err.message); console.error('Error fetching user profile:', err?.message || err);
res.status(500).json({ error: 'Internal server error' }); return res.status(500).json({ error: 'Internal server error' });
} }
}); });
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------
SALARY_INFO REMAINS IN SQLITE SALARY_INFO REMAINS IN SQLITE
------------------------------------------------------------------ */ ------------------------------------------------------------------ */

View File

@ -60,9 +60,6 @@ const chatLimiter = rateLimit({
keyGenerator: req => req.user?.id || req.ip keyGenerator: req => req.user?.id || req.ip
}); });
// ── RUNTIME PROTECTION: outbound host allowlist (server2) ── // ── RUNTIME PROTECTION: outbound host allowlist (server2) ──
const OUTBOUND_ALLOW = new Set([ const OUTBOUND_ALLOW = new Set([
'services.onetcenter.org', // O*NET 'services.onetcenter.org', // O*NET
@ -199,6 +196,7 @@ const EXEMPT_PATHS = [
app.use((req, res, next) => { app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next(); if (!req.path.startsWith('/api/')) return next();
if (isHotReloadPath(req)) return next();
if (!MUST_JSON.has(req.method)) return next(); if (!MUST_JSON.has(req.method)) return next();
if (EXEMPT_PATHS.some(rx => rx.test(req.path))) 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)); process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
// ---- RUNTIME PROTECTION: HPP guard (dedupe + cap arrays) ---- // ---- RUNTIME PROTECTION: HPP guard (dedupe + cap arrays) ----
app.use((req, _res, next) => { 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) => { const sanitize = (obj) => {
if (!obj || typeof obj !== 'object') return; if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) { for (const k of Object.keys(obj)) {
const v = obj[k]; const v = obj[k];
if (Array.isArray(v)) { if (Array.isArray(v)) {
// keep first value semantics + bound array size
obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null); 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 ---- // ---- RUNTIME: reject request bodies on GET/HEAD ----
app.use((req, res, next) => { app.use((req, res, next) => {
if (isHotReloadPath(req)) return next();
if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) { if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) {
return res.status(400).json({ error: 'no_body_allowed' }); 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 // 3) Health: detailed JSON you can curl
app.get('/healthz', async (_req, res) => { app.get('/healthz', async (_req, res) => {
const out = { const out = {
@ -483,6 +495,10 @@ const supportDailyLimiter = rateLimit({
* DB connections (SQLite) * DB connections (SQLite)
**************************************************/ **************************************************/
let dbSqlite; 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; let userProfileDb;
async function initDatabases() { async function initDatabases() {
@ -493,6 +509,30 @@ async function initDatabases() {
mode : sqlite3.OPEN_READONLY mode : sqlite3.OPEN_READONLY
}); });
console.log('✅ Connected to salary_info.db'); 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({ userProfileDb = await open({
filename: USER_PROFILE_DB_PATH, filename: USER_PROFILE_DB_PATH,
@ -563,16 +603,72 @@ app.use((req, res, next) => next());
// ──────────────────────────────── Data endpoints ─────────────────────────────── // ──────────────────────────────── Data endpoints ───────────────────────────────
// /api/data/careers-with-ratings → backend/data/careers_with_ratings.json /**************************************************
app.get('/api/data/careers-with-ratings', (req, res) => { * BULK limited-data computation (single call)
const p = path.join(DATA_DIR, 'careers_with_ratings.json'); * - Input: { socCodes: [full SOCs], state, area }
fs.access(p, fs.constants.R_OK, (err) => { * - Output: { [fullSoc]: { job_zone, limitedData } }
if (err) return res.status(404).json({ error: 'careers_with_ratings.json not found' }); **************************************************/
res.type('application/json'); app.post('/api/suggestions/limited-data', async (req, res) => {
res.sendFile(p); 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) // /api/data/cip-institution-map → backend/data/cip_institution_mapping_new.json (or fallback)
app.get('/api/data/cip-institution-map', (req, res) => { app.get('/api/data/cip-institution-map', (req, res) => {
const candidates = [ const candidates = [
@ -621,6 +717,13 @@ const socToCipMapping = loadMapping();
if (socToCipMapping.length === 0) { if (socToCipMapping.length === 0) {
console.error('SOC to CIP mapping data is empty.'); 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 * Load single JSON with all states + US
@ -636,6 +739,25 @@ try {
console.error('Error reading economicproj.json:', err); 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 //AI At Risk helpers
async function getRiskAnalysisFromDB(socCode) { async function getRiskAnalysisFromDB(socCode) {
const row = await userProfileDb.get( const row = await userProfileDb.get(
@ -645,6 +767,96 @@ async function getRiskAnalysisFromDB(socCode) {
return row || null; 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 // Helper to upsert a row
async function storeRiskAnalysisInDB({ async function storeRiskAnalysisInDB({
socCode, socCode,
@ -970,18 +1182,11 @@ app.get('/api/onet/career-description/:socCode', async (req, res) => {
// CIP route // CIP route
app.get('/api/cip/:socCode', (req, res) => { app.get('/api/cip/:socCode', (req, res) => {
const { socCode } = req.params; const { socCode } = req.params;
console.log(`Received SOC Code: ${socCode.trim()}`); const key = String(socCode || '').trim();
const cip = CIP_BY_SOC.get(key);
for (let row of socToCipMapping) { if (cip) return res.json({ cipCode: cip });
const mappedSOC = row['O*NET-SOC 2019 Code']?.trim(); return res.status(404).json({ error: 'CIP code not found' });
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' });
});
/** @aiTool { /** @aiTool {
"name": "getSchoolsForCIPs", "name": "getSchoolsForCIPs",
@ -1142,60 +1347,19 @@ app.get('/api/tuition', (req, res) => {
/************************************************** /**************************************************
* SINGLE route for projections from economicproj.json * SINGLE route for projections from economicproj.json
**************************************************/ **************************************************/
app.get('/api/projections/:socCode', (req, res) => { app.get('/api/projections/:socCode', (req, res) => {
const { socCode } = req.params; const { socCode } = req.params;
const { state } = req.query; const { state } = req.query;
console.log('Projections request for', socCode, ' state=', state); const occ = String(socCode).trim();
const areaKey = String(state ? state : 'United States').trim().toLowerCase();
if (!socCode) { const rowState = PROJ_BY_KEY.get(`${occ}|${areaKey}`) || null;
return res.status(400).json({ error: 'SOC Code is required.' }); 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 { /** @aiTool {
"name": "getSalaryData", "name": "getSalaryData",
@ -1217,49 +1381,51 @@ app.get('/api/projections/:socCode', (req, res) => {
**************************************************/ **************************************************/
app.get('/api/salary', async (req, res) => { app.get('/api/salary', async (req, res) => {
const { socCode, area } = req.query; const { socCode, area } = req.query;
console.log('Received /api/salary request:', { socCode, area });
if (!socCode) { if (!socCode) {
return res.status(400).json({ error: 'SOC Code is required' }); 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 { try {
let regionalRow = null; const keyArea = String(area || ''); // allow empty → national only
let nationalRow = null; const cacheKey = `${socCode}|${keyArea}`;
const cached = SALARY_CACHE.get(cacheKey);
if (cached) return res.json(cached);
if (area) { // Bind regional placeholders (five times) + occ + area
regionalRow = await dbSqlite.get(regionalQuery, [socCode, area]); const row = await SALARY_STMT.get(
} keyArea, keyArea, keyArea, keyArea, keyArea, socCode, keyArea
nationalRow = await dbSqlite.get(nationalQuery, [socCode]); );
if (!regionalRow && !nationalRow) { const regional = {
console.log('No salary data found for:', { socCode, area }); 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' }); return res.status(404).json({ error: 'No salary data found' });
} }
const salaryData = {
regional: regionalRow || {}, const payload = { regional, national };
national: nationalRow || {}, // Tiny LRU: cap at 512 entries
}; SALARY_CACHE.set(cacheKey, payload);
console.log('Salary data retrieved:', salaryData); if (SALARY_CACHE.size > SALARY_CACHE_MAX) {
res.json(salaryData); const first = SALARY_CACHE.keys().next().value;
SALARY_CACHE.delete(first);
}
res.json(payload);
} catch (error) { } catch (error) {
console.error('Error executing salary query:', error.message); console.error('Error executing salary query:', error.message);
res.status(500).json({ error: 'Failed to fetch salary data' }); 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 * AI RISK ASSESSMENT ENDPOINT READ
****************************************************/ ****************************************************/
@ -1498,9 +1643,25 @@ app.post(
accountEmail = row?.email || null; accountEmail = row?.email || null;
} catch {} } catch {}
} }
// If still missing, fetch from server1 using the caller's session
if (!accountEmail) { 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) { if (!accountEmail) {
return res.status(400).json({ error: 'No email on file for this user' }); return res.status(400).json({ error: 'No email on file for this user' });
} }

View File

@ -47,11 +47,7 @@ const API_BASE = `${INTERNAL_SELF_BASE}/api`;
const DATA_DIR = path.join(__dirname, 'data'); const DATA_DIR = path.join(__dirname, 'data');
/* ─── helper: canonical public origin ─────────────────────────── */ /* ─── helper: canonical public origin ─────────────────────────── */
const PUBLIC_BASE = ( const PUBLIC_BASE = (process.env.APTIVA_API_BASE || '').replace(/\/+$/, '');
process.env.APTIVA_AI_BASE
|| process.env.REACT_APP_API_URL
|| ''
).replace(/\/+$/, '');
const ALLOWED_REDIRECT_HOSTS = new Set([ const ALLOWED_REDIRECT_HOSTS = new Set([
new URL(PUBLIC_BASE || 'http://localhost').host new URL(PUBLIC_BASE || 'http://localhost').host
@ -60,7 +56,7 @@ const ALLOWED_REDIRECT_HOSTS = new Set([
// ── RUNTIME PROTECTION: outbound host allowlist (server3) ── // ── RUNTIME PROTECTION: outbound host allowlist (server3) ──
const OUTBOUND_ALLOW = new Set([ const OUTBOUND_ALLOW = new Set([
'server2', // compose DNS (server2:5001) 'server2', // compose DNS (server2:5001)
'server3', // self-calls (localhost:5002) 'server3', // self-calls (server3:5002)
'api.openai.com', // OpenAI SDK traffic 'api.openai.com', // OpenAI SDK traffic
'api.stripe.com', // Stripe SDK traffic 'api.stripe.com', // Stripe SDK traffic
'api.twilio.com' // smsService may hit Twilio from this proc 'api.twilio.com' // smsService may hit Twilio from this proc
@ -93,9 +89,10 @@ const app = express();
app.use(cookieParser()); app.use(cookieParser());
app.disable('x-powered-by'); app.disable('x-powered-by');
app.set('trust proxy', 1); app.set('trust proxy', 1);
app.use(express.json({ limit: '1mb' }));
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false })); app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
// --- Request ID + minimal audit log for /api/* --- // --- Request ID + minimal audit log for /api/* ---
function getRequestId(req, res) { function getRequestId(req, res) {
const hdr = req.headers['x-request-id']; const hdr = req.headers['x-request-id'];
@ -507,6 +504,34 @@ async function getRiskAnalysisFromDB(socCode) {
return rows.length > 0 ? rows[0] : null; 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({ async function storeRiskAnalysisInDB({
socCode, socCode,
careerName, careerName,
@ -559,24 +584,133 @@ const COOKIE_NAME = process.env.COOKIE_NAME || 'aptiva_session';
// GET current user's draft // GET current user's draft
app.get('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { app.get('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => {
const [[row]] = await pool.query( const [[row]] = await pool.query(
'SELECT id, step, data FROM onboarding_drafts WHERE user_id=?', `SELECT id, step, data
FROM onboarding_drafts
WHERE user_id=?
ORDER BY updated_at DESC, id DESC
LIMIT 1`,
[req.id] [req.id]
); );
return res.json(row || null); 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) => { app.post('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => {
const { id, step = 0, data = {} } = req.body || {}; try {
const draftId = id || uuidv4(); // ---- 0) Harden req.body and incoming shapes
await pool.query(` const body = (req && typeof req.body === 'object' && req.body) ? req.body : {};
INSERT INTO onboarding_drafts (user_id,id,step,data) let { id, step } = body;
VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE step=VALUES(step), data=VALUES(data) // Accept either {data:{careerData/financialData/collegeData}} or section keys at top level
`, [req.id, draftId, step, JSON.stringify(data)]); let incoming = {};
res.json({ id: draftId, step }); 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) // DELETE draft (after finishing / cancelling)
app.delete('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => { app.delete('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => {
await pool.query('DELETE FROM onboarding_drafts WHERE user_id=?', [req.id]); 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); console.error('⚠️ Bad Stripe signature', err.message);
return res.status(400).end(); 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); const h = hashForLookup(customerId);
console.log('[Stripe] upFlags', { customerId, premium, pro });
await pool.query( await pool.query(
`UPDATE user_profile `UPDATE user_profile
SET is_premium = ?, is_pro_premium = ? SET is_premium = ?, is_pro_premium = ?
WHERE stripe_customer_id_hash = ?`, WHERE stripe_customer_id_hash = ?`,
[premium, pro, h] [premium ? 1 : 0, pro ? 1 : 0, h]
); );
}; };
// 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) { switch (event.type) {
case 'customer.subscription.created': case 'customer.subscription.created':
case 'customer.subscription.updated': { 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;
}
case 'customer.subscription.deleted': { case 'customer.subscription.deleted': {
const sub = event.data.object; 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; break;
} }
default: default:
@ -634,8 +809,11 @@ app.post(
); );
// 2) Basic middlewares // 2) Basic middlewares
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false })); app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
//leave below Stripe webhook
app.use(express.json({ limit: '5mb' })); app.use(express.json({ limit: '5mb' }));
/* ─── Require critical env vars ─────────────────────────────── */ /* ─── Require critical env vars ─────────────────────────────── */
@ -684,10 +862,6 @@ function authenticatePremiumUser(req, res, next) {
} }
}; };
/** ------------------------------------------------------------------
* Returns the users stripe_customer_id (or null) given req.id.
* Creates a new Stripe Customer & saves it if missing.
* ----------------------------------------------------------------- */
/** ------------------------------------------------------------------ /** ------------------------------------------------------------------
* Returns the users Stripe customerid (decrypted) given req.id. * Returns the users Stripe customerid (decrypted) given req.id.
@ -726,6 +900,21 @@ async function getOrCreateStripeCustomerId(req) {
return customer.id; 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 = { const priceMap = {
@ -856,17 +1045,16 @@ async function ensureDescriptionAndTasks({ socCode, jobDescription, tasks }) {
// GET the latest selected career profile // GET the latest selected career profile
app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (req, res) => { app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (req, res) => {
try { try {
const sql = ` const [rows] = await pool.query(
SELECT `SELECT * FROM career_profiles
*,
start_date AS start_date
FROM career_profiles
WHERE user_id = ? WHERE user_id = ?
ORDER BY start_date DESC ORDER BY start_date DESC
LIMIT 1 LIMIT 1`,
`; [req.id]
const [rows] = await pool.query(sql, [req.id]); );
res.json(rows[0] || {}); const row = rows[0] ? { ...rows[0] } : null;
if (row) delete row.user_id;
return res.json(row || {});
} catch (error) { } catch (error) {
console.error('Error fetching latest career profile:', error); console.error('Error fetching latest career profile:', error);
res.status(500).json({ error: 'Failed to fetch latest career profile' }); 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 { try {
const sql = ` const sql = `
SELECT SELECT
*, id,
start_date AS start_date 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 FROM career_profiles
WHERE user_id = ? WHERE user_id = ?
ORDER BY start_date ASC ORDER BY start_date ASC
@ -910,7 +1102,9 @@ app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser,
if (!rows[0]) { if (!rows[0]) {
return res.status(404).json({ error: 'Career profile not found or not yours.' }); 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) { } catch (error) {
console.error('Error fetching single career profile:', error); console.error('Error fetching single career profile:', error);
res.status(500).json({ error: 'Failed to fetch career profile by ID.' }); res.status(500).json({ error: 'Failed to fetch career profile by ID.' });
@ -2627,7 +2821,6 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
createdMilestones.push({ createdMilestones.push({
id, id,
user_id: req.id,
career_profile_id, career_profile_id,
title, title,
description: description || '', description: description || '',
@ -2697,7 +2890,17 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
is_universal: is_universal ? 1 : 0, is_universal: is_universal ? 1 : 0,
tasks: [] 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) { } catch (err) {
console.error('Error creating milestone(s):', err); console.error('Error creating milestone(s):', err);
res.status(500).json({ error: 'Failed to create milestone(s).' }); res.status(500).json({ error: 'Failed to create milestone(s).' });
@ -2777,9 +2980,9 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
`, [milestoneId]); `, [milestoneId]);
res.json({ res.json({
...updatedMilestoneRow, ...safeMilestoneRow(updatedMilestoneRow),
tasks: tasks || [] tasks: (tasks || []).map(safeTaskRow)
}); });
} catch (err) { } catch (err) {
console.error('Error updating milestone:', err); console.error('Error updating milestone:', err);
res.status(500).json({ error: 'Failed to update milestone.' }); 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) => { tasksByMilestone = taskRows.reduce((acc, t) => {
if (!acc[t.milestone_id]) acc[t.milestone_id] = []; if (!acc[t.milestone_id]) acc[t.milestone_id] = [];
acc[t.milestone_id].push(t); acc[t.milestone_id].push(safeTaskRow(t));
return acc; return acc;
}, {}); }, {});
} }
const uniMils = universalRows.map(m => ({ const uniMils = universalRows.map(m => ({
...m, ...safeMilestoneRow(m),
tasks: tasksByMilestone[m.id] || [] tasks: tasksByMilestone[m.id] || []
})); }));
return res.json({ milestones: uniMils }); return res.json({ milestones: uniMils });
@ -2843,13 +3046,13 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
tasksByMilestone = taskRows.reduce((acc, t) => { tasksByMilestone = taskRows.reduce((acc, t) => {
if (!acc[t.milestone_id]) acc[t.milestone_id] = []; if (!acc[t.milestone_id]) acc[t.milestone_id] = [];
acc[t.milestone_id].push(t); acc[t.milestone_id].push(safeTaskRow(t));
return acc; return acc;
}, {}); }, {});
} }
const milestonesWithTasks = milestones.map(m => ({ const milestonesWithTasks = milestones.map(m => ({
...m, ...safeMilestoneRow(m),
tasks: tasksByMilestone[m.id] || [] tasks: tasksByMilestone[m.id] || []
})); }));
res.json({ milestones: milestonesWithTasks }); 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) => { app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => {
try { try {
const [rows] = await pool.query( 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] [req.id]
); );
if (!rows.length) { if (!rows.length) {
// minimal, id-free default payload
return res.json({ return res.json({
current_salary: 0, current_salary: 0,
additional_income: 0, additional_income: 0,
@ -3131,7 +3347,20 @@ app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, r
extra_cash_retirement_pct: 50 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) { } catch (err) {
console.error('financialprofile GET error:', err); console.error('financialprofile GET error:', err);
res.status(500).json({ error: 'DB error' }); 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) => { app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res) => {
const { careerProfileId } = req.query; const { careerProfileId } = req.query;
try { try {
const [rows] = await pool.query(` const [rows] = await pool.query(
SELECT * `SELECT *
FROM college_profiles FROM college_profiles
WHERE user_id = ? WHERE user_id = ?
AND career_profile_id = ? AND career_profile_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT 1 LIMIT 1`,
`, [req.id, careerProfileId]); [req.id, careerProfileId]
);
if (!rows[0]) return res.status(404).json({ error: 'No college profile for this scenario' }); 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) { } catch (error) {
console.error('Error fetching college profile:', error); console.error('Error fetching college profile:', error);
res.status(500).json({ error: 'Failed to fetch college profile.' }); res.status(500).json({ error: 'Failed to fetch college profile.' });
@ -3387,11 +3619,15 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res
}); });
// GET every college profile for the loggedin user // GET every college profile for the loggedin 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 = ` const sql = `
SELECT cp.*, SELECT
DATE_FORMAT(cp.created_at,'%Y-%m-%d') AS created_at, cp.career_profile_id,
IFNULL(cpr.scenario_title, cpr.career_name) AS career_title 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 FROM college_profiles cp
JOIN career_profiles cpr JOIN career_profiles cpr
ON cpr.id = cp.career_profile_id ON cpr.id = cp.career_profile_id
@ -3400,19 +3636,98 @@ app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req,
ORDER BY cp.created_at DESC ORDER BY cp.created_at DESC
`; `;
const [rows] = await pool.query(sql, [req.id]); const [rows] = await pool.query(sql, [req.id]);
const decrypted = rows.map(r => {
const row = { ...r }; // 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']) { 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:')) { 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 AI-SUGGESTED MILESTONES
------------------------------------------------------------------ */ ------------------------------------------------------------------ */
@ -3669,7 +3984,6 @@ app.post('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
const newTask = { const newTask = {
id: taskId, id: taskId,
milestone_id, milestone_id,
user_id: req.id,
title, title,
description: description || '', description: description || '',
due_date: due_date || null, due_date: due_date || null,
@ -3733,10 +4047,10 @@ app.put('/api/premium/tasks/:taskId', authenticatePremiumUser, async (req, res)
]); ]);
const [[updatedTask]] = await pool.query(` const [[updatedTask]] = await pool.query(`
SELECT * SELECT id, milestone_id, title, description, due_date, status, created_at, updated_at
FROM tasks FROM tasks
WHERE id = ? WHERE id = ?
`, [taskId]); `, [taskId]);
res.json(updatedTask); res.json(updatedTask);
} catch (err) { } catch (err) {
console.error('Error updating task:', err); console.error('Error updating task:', err);
@ -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', app.post('/api/premium/stripe/create-checkout-session',
authenticatePremiumUser, authenticatePremiumUser,
async (req, res) => { 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 safeSuccess = success_url && isSafeRedirect(success_url) ? success_url : defaultSuccess;
const safeCancel = cancel_url && isSafeRedirect(cancel_url) ? cancel_url : defaultCancel; const safeCancel = cancel_url && isSafeRedirect(cancel_url) ? cancel_url : defaultCancel;
const session = await stripe.checkout.sessions.create({ // 👇 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', mode : 'subscription',
customer : customerId, customer : customerId,
line_items : [{ price: priceId, quantity: 1 }], line_items : [{ price: priceId, quantity: 1 }],
allow_promotion_codes : true, allow_promotion_codes : false,
success_url : safeSuccess, success_url : `${safeSuccess}`,
cancel_url : safeCancel 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 }); return res.json({ url: session.url });
} catch (err) { } catch (err) {
console.error('create-checkout-session failed:', err?.raw?.message || err); console.error('create-checkout-session failed:', err?.raw?.message || err);
@ -4548,8 +4884,7 @@ app.get('/api/premium/stripe/customer-portal',
try { try {
const base = PUBLIC_BASE || `https://${req.headers.host}`; const base = PUBLIC_BASE || `https://${req.headers.host}`;
const { return_url } = req.query; 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 cid = await getOrCreateStripeCustomerId(req);
const portal = await stripe.billingPortal.sessions.create({ const portal = await stripe.billingPortal.sessions.create({

View File

@ -1,42 +1,44 @@
// shared/auth/requireAuth.js // backend/shared/requireAuth.js
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import pool from '../config/mysqlPool.js'; import pool from '../config/mysqlPool.js';
const { function readSessionCookie(req, cookieName) {
JWT_SECRET, if (req.cookies && req.cookies[cookieName]) return req.cookies[cookieName];
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
const raw = req.headers.cookie || ''; const raw = req.headers.cookie || '';
for (const part of raw.split(';')) { for (const part of raw.split(';')) {
const [k, ...rest] = part.trim().split('='); const [k, ...rest] = part.trim().split('=');
if (k === COOKIE_NAME) return decodeURIComponent(rest.join('=')); if (k === cookieName) return decodeURIComponent(rest.join('='));
} }
return null; 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) { export async function requireAuth(req, res, next) {
try { 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 || ''; const authz = req.headers.authorization || '';
let token = const token = authz.startsWith('Bearer ')
authz.startsWith('Bearer ')
? authz.slice(7) ? authz.slice(7)
: readSessionCookie(req); : readSessionCookie(req, COOKIE_NAME);
if (!token) return res.status(401).json({ error: 'Auth required' }); if (!token) return res.status(401).json({ error: 'Auth required' });
// 2) Verify JWT // 2) Verify
let payload; let payload;
try { payload = jwt.verify(token, JWT_SECRET); } try { payload = jwt.verify(token, JWT_SECRET); }
catch { return res.status(401).json({ error: 'Invalid or expired token' }); } 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 userId = payload.id;
const iatMs = (payload.iat || 0) * 1000; 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) { if (MAX_AGE && Date.now() - iatMs > MAX_AGE) {
return res.status(401).json({ error: 'Session expired. Please sign in again.' }); return res.status(401).json({ error: 'Session expired. Please sign in again.' });
} }
// 4) Invalidate tokens issued before last password change // 4) Password change invalidation
let changedAtMs = 0;
try {
const sql = pool.raw || pool; const sql = pool.raw || pool;
const [rows] = await sql.query( const [rows] = await sql.query(
'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1', 'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId] [userId]
); );
const changedAtMs = rows?.[0]?.password_changed_at changedAtMs = toMs(rows?.[0]?.password_changed_at);
? new Date(rows[0].password_changed_at).getTime() } catch (e) {
: 0; console.warn('[requireAuth] password_changed_at check skipped:', e?.message || e);
}
if (changedAtMs && iatMs < changedAtMs) { if (changedAtMs && iatMs < changedAtMs) {
return res.status(401).json({ error: 'Session invalidated. Please sign in again.' }); return res.status(401).json({ error: 'Session invalidated. Please sign in again.' });
} }
req.userId = userId; req.userId = userId;
next(); return next();
} catch (e) { } catch (e) {
console.error('[requireAuth]', e?.message || e); console.error('[requireAuth]', e?.message || e);
return res.status(500).json({ error: 'Server error' }); return res.status(500).json({ error: 'Server error' });

View File

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

View File

@ -133,6 +133,7 @@ services:
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
APTIVA_API_BASE: ${APTIVA_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY} OPENAI_API_KEY: ${OPENAI_API_KEY}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}

View File

@ -1,11 +1,14 @@
events {} worker_rlimit_nofile 131072;
events { worker_connections 16384;
}
http { http {
keepalive_requests 10000;
include /etc/nginx/mime.types; include /etc/nginx/mime.types;
default_type application/octet-stream; default_type application/octet-stream;
resolver 127.0.0.11 ipv6=off; resolver 127.0.0.11 ipv6=off;
limit_conn_zone $binary_remote_addr zone=perip:10m; 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 130.211.0.0/22;
set_real_ip_from 35.191.0.0/16; set_real_ip_from 35.191.0.0/16;
real_ip_header X-Forwarded-For; real_ip_header X-Forwarded-For;
@ -13,7 +16,8 @@ http {
# ───────────── upstreams to Docker services ───────────── # ───────────── upstreams to Docker services ─────────────
upstream backend5000 { server server1:5000; } # auth & free 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 backend5002 { server server3:5002; } # premium
upstream gitea_backend { server gitea:3000; } # gitea service (shared network) upstream gitea_backend { server gitea:3000; } # gitea service (shared network)
upstream woodpecker_backend { server woodpecker-server:8000; } upstream woodpecker_backend { server woodpecker-server:8000; }
@ -33,14 +37,17 @@ http {
######################################################################## ########################################################################
server { server {
listen 443 ssl; 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; server_name dev1.aptivaai.com;
ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem; ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3; ssl_protocols TLSv1.2 TLSv1.3;
# ==== RUNTIME PROTECTIONS (dev test) ==== # ==== RUNTIME PROTECTIONS ====
server_tokens off; server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always; add_header X-Content-Type-Options nosniff always;
@ -63,12 +70,6 @@ http {
proxy_buffers 8 16k; proxy_buffers 8 16k;
proxy_busy_buffers_size 32k; 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 ($request_method !~ ^(GET|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; }
if ($host !~* ^(dev1\.aptivaai\.com)$) { return 444; } if ($host !~* ^(dev1\.aptivaai\.com)$) { return 444; }
@ -89,25 +90,92 @@ http {
} }
# ───── API reverseproxy rules ───── # ───── API reverseproxy rules ─────
location ^~ /api/onet/ { proxy_pass http://backend5001; } location ^~ /api/onet/ {
location ^~ /api/chat/ { proxy_pass http://backend5001; proxy_http_version 1.1; proxy_buffering off; } proxy_http_version 1.1;
location ^~ /api/job-zones { proxy_pass http://backend5001; } proxy_set_header Connection "";
location ^~ /api/salary { proxy_pass http://backend5001; } proxy_read_timeout 90s;
location ^~ /api/cip/ { proxy_pass http://backend5001; } 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/tuition/ { proxy_pass http://backend5001; }
location ^~ /api/projections/ { proxy_pass http://backend5001; }
location ^~ /api/skills/ { proxy_pass http://backend5001; } location ^~ /api/skills/ { proxy_pass http://backend5001; }
location ^~ /api/maps/distance { proxy_pass http://backend5001; } location ^~ /api/maps/distance { proxy_pass http://backend5001; }
location ^~ /api/schools { 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/data/ { proxy_pass http://backend5001; }
location ^~ /api/careers/ { proxy_pass http://backend5001; } location ^~ /api/careers/ { proxy_pass http://backend5001; }
location ^~ /api/programs/ { 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/public/ { proxy_pass http://backend5002; }
location ^~ /api/ai-risk { 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; } location ^~ /api/ { proxy_pass http://backend5000; }
# shared proxy headers # shared proxy headers

View File

@ -31,6 +31,7 @@ import CollegeProfileForm from './components/CollegeProfileForm.js';
import CareerRoadmap from './components/CareerRoadmap.js'; import CareerRoadmap from './components/CareerRoadmap.js';
import Paywall from './components/Paywall.js'; import Paywall from './components/Paywall.js';
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js'; import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
import { isOnboardingInProgress } from './utils/onboardingGuard.js';
import RetirementPlanner from './components/RetirementPlanner.js'; import RetirementPlanner from './components/RetirementPlanner.js';
import ResumeRewrite from './components/ResumeRewrite.js'; import ResumeRewrite from './components/ResumeRewrite.js';
import LoanRepaymentPage from './components/LoanRepaymentPage.js'; import LoanRepaymentPage from './components/LoanRepaymentPage.js';
@ -68,12 +69,16 @@ function App() {
const [drawerPane, setDrawerPane] = useState('support'); const [drawerPane, setDrawerPane] = useState('support');
const [retireProps, setRetireProps] = useState(null); const [retireProps, setRetireProps] = useState(null);
const [supportOpen, setSupportOpen] = useState(false); const [supportOpen, setSupportOpen] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loggingOut, setLoggingOut] = useState(false); const [loggingOut, setLoggingOut] = useState(false);
const AUTH_HOME = '/signin-landing'; 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 ChatDrawer route-aware tool handlers
------------------------------------------ */ ------------------------------------------ */
@ -99,6 +104,40 @@ const uiToolHandlers = useMemo(() => {
return {}; // every other page exposes no UI tools return {}; // every other page exposes no UI tools
}, [pageContext]); }, [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 // Retirement bot is only relevant on these pages
const canShowRetireBot = const canShowRetireBot =
pageContext === 'RetirementPlanner' || pageContext === 'RetirementPlanner' ||
@ -151,18 +190,6 @@ const showPremiumCTA = !premiumPaths.some(p =>
location.pathname.startsWith(p) location.pathname.startsWith(p)
); );
// Helper to see if user is midpremium-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 // 1) Single Rehydrate UseEffect
// ============================== // ==============================
@ -188,10 +215,15 @@ if (loggingOut) return;
(async () => { (async () => {
setIsLoading(true); setIsLoading(true);
try { try {
// axios client already: withCredentials + Bearer from authMemory // Fetch only the minimal fields App needs for nav/landing/support
const { data } = await api.get('/api/user-profile'); const { data } = await api.get('/api/user-profile?fields=firstname,is_premium,is_pro_premium');
if (cancelled) return; 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); setIsAuthenticated(true);
} catch (err) { } catch (err) {
if (cancelled) return; if (cancelled) return;
@ -218,14 +250,6 @@ if (loggingOut) return;
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, navigate, loggingOut]); }, [location.pathname, navigate, loggingOut]);
/* =====================
Support Modal Email
===================== */
useEffect(() => {
setUserEmail(user?.email || '');
}, [user]);
// ========================== // ==========================
// 2) Logout Handler + Modal // 2) Logout Handler + Modal
// ========================== // ==========================
@ -239,14 +263,12 @@ if (loggingOut) return;
} }
}; };
const confirmLogout = async () => { const confirmLogout = async () => {
setLoggingOut(true); setLoggingOut(true);
// 1) Ask the server to clear the session cookie // 1) Ask the server to clear the session cookie
try { try {
// If you created /logout (no /api prefix): // 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: // If your route is /api/signout instead, use:
// await api.post('/api/signout'); // await api.post('/api/signout');
} catch (e) { } catch (e) {
@ -296,8 +318,6 @@ const cancelLogout = () => {
); );
} }
// ===================== // =====================
// Main Render / Layout // Main Render / Layout
// ===================== // =====================
@ -558,7 +578,6 @@ const cancelLogout = () => {
<SupportModal <SupportModal
open={supportOpen} open={supportOpen}
onClose={() => setSupportOpen(false)} onClose={() => setSupportOpen(false)}
userEmail={userEmail}
/> />
{/* LOGOUT BUTTON */} {/* LOGOUT BUTTON */}

View File

@ -1,45 +1,47 @@
import { useEffect, useState, useContext } from 'react'; import { useEffect, useState } from 'react';
import { useLocation, Link } from 'react-router-dom'; import { useLocation, Link } from 'react-router-dom';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import { ProfileCtx } from '../App.js';
import api from '../auth/apiClient.js'; import api from '../auth/apiClient.js';
export default function BillingResult() { export default function BillingResult() {
const { setUser } = useContext(ProfileCtx) || {};
const q = new URLSearchParams(useLocation().search); const q = new URLSearchParams(useLocation().search);
const outcome = q.get('ck'); // 'success' | 'cancel' | null const outcome = q.get('ck'); // 'success' | 'cancel' | 'portal' | null
const [loading, setLoading] = useState(true);
/* const [loading, setLoading] = useState(true);
1) Ask the API for the latest user profile (flags, etc.) const [flags, setFlags] = useState({ is_premium: false, is_pro_premium: false });
cookies + in-mem token handled by apiClient
*/ // ─────────────────────────────────────────────────────────
// 1) Always fetch the latest subscription flags from backend
// ─────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const { data } = await api.get('/api/user-profile'); const { data } = await api.get('/api/premium/subscription/status');
if (!cancelled && data && setUser) setUser(data); if (!cancelled && data) {
setFlags({
is_premium: !!data.is_premium,
is_pro_premium: !!data.is_pro_premium,
});
}
} catch (err) { } catch (err) {
// Non-fatal here; UI still shows outcome console.warn('[BillingResult] failed to refresh flags', err?.message);
console.warn('[BillingResult] failed to refresh profile', err?.response?.status || err?.message);
} finally { } finally {
if (!cancelled) setLoading(false); if (!cancelled) setLoading(false);
} }
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [setUser]); }, []);
/*
2) UX while waiting for that roundtrip
*/
if (loading) { if (loading) {
return <p className="p-8 text-center">Checking your subscription</p>; return <p className="p-8 text-center">Checking your subscription</p>;
} }
/* const hasPremium = flags.is_premium || flags.is_pro_premium;
3) Success Stripe completed the checkout flow
*/ // ─────────────────────────────────────────────────────────
// 2) Success (Checkout completed)
// ─────────────────────────────────────────────────────────
if (outcome === 'success') { if (outcome === 'success') {
return ( return (
<div className="max-w-md mx-auto p-8 text-center space-y-6"> <div className="max-w-md mx-auto p-8 text-center space-y-6">
@ -47,29 +49,59 @@ export default function BillingResult() {
<p className="text-gray-600"> <p className="text-gray-600">
Premium features have been unlocked on your account. Premium features have been unlocked on your account.
</p> </p>
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link to="/premium-onboarding" className="block w-full">Set up Premium Features</Link> <Link to="/premium-onboarding">Set up Premium Features</Link>
</Button> </Button>
<Button variant="secondary" asChild className="w-full"> <Button variant="secondary" asChild className="w-full">
<Link to="/profile" className="block w-full">Go to my account</Link> <Link to="/profile">Go to my account</Link>
</Button> </Button>
</div> </div>
); );
} }
/* // ─────────────────────────────────────────────────────────
4) Cancelled user backed out of Stripe // 3) Portal return
*/ // ─────────────────────────────────────────────────────────
if (outcome === 'portal') {
return ( return (
<div className="max-w-md mx-auto p-8 text-center space-y-6"> <div className="max-w-md mx-auto p-8 text-center space-y-6">
<h1 className="text-2xl font-semibold">Subscription cancelled</h1> <h1 className="text-2xl font-semibold">Billing updated</h1>
<p className="text-gray-600">No changes were made to your account.</p> <p className="text-gray-600">
{hasPremium ? 'Your subscription is active.' : 'No active subscription on your account.'}
</p>
<Button asChild className="w-full"> <Button asChild className="w-full">
<Link to="/paywall" className="block w-full">Back to pricing</Link> <Link to="/profile">Go to my account</Link>
</Button> </Button>
{!hasPremium && (
<Button asChild className="w-full">
<Link to="/paywall">Back to pricing</Link>
</Button>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────
// 4) Cancelled checkout or direct visit
// ─────────────────────────────────────────────────────────
return (
<div className="max-w-md mx-auto p-8 text-center space-y-6">
<h1 className="text-2xl font-semibold">
{hasPremium ? 'Subscription active' : 'No subscription changes'}
</h1>
<p className="text-gray-600">
{hasPremium
? 'You still have premium access.'
: 'No active subscription on your account.'}
</p>
<Button asChild className="w-full">
<Link to="/profile">Go to my account</Link>
</Button>
{!hasPremium && (
<Button asChild className="w-full">
<Link to="/paywall">Back to pricing</Link>
</Button>
)}
</div> </div>
); );
} }

File diff suppressed because it is too large Load Diff

View File

@ -22,11 +22,17 @@ const nav = useNavigate();
})(); })();
}, []); }, []);
async function remove(id) { async function remove(row) {
if (!window.confirm('Delete this career profile?')) return; if (!window.confirm('Delete this career profile?')) return;
try { try {
const r = await apiFetch(`/api/premium/career-profile/${id}`, { const r = await apiFetch(`/api/premium/career-profile/by-fields`, {
method: 'DELETE', 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) { if (!r.ok) {
// 401/403 will already be handled by apiFetch // 401/403 will already be handled by apiFetch
@ -34,7 +40,13 @@ const nav = useNavigate();
alert(msg || 'Failed to delete'); alert(msg || 'Failed to delete');
return; 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) { } catch (e) {
console.error('Delete failed:', e); console.error('Delete failed:', e);
alert('Failed to delete'); alert('Failed to delete');
@ -69,13 +81,11 @@ const nav = useNavigate();
<td className="p-2">{r.start_date}</td> <td className="p-2">{r.start_date}</td>
<td className="p-2 space-x-2"> <td className="p-2 space-x-2">
<Link <Link
to={`/profile/careers/${r.id}/edit`} to={`/profile/careers/${encodeURIComponent(r.id)}/edit`}
className="underline text-blue-600" className="underline text-blue-600"
> >edit</Link>
edit
</Link>
<button <button
onClick={() => remove(r.id)} onClick={() => remove(r)}
className="text-red-600 underline" className="text-red-600 underline"
> >
delete delete
@ -85,8 +95,21 @@ const nav = useNavigate();
))} ))}
{rows.length === 0 && ( {rows.length === 0 && (
<tr> <tr>
<td colSpan={5} className="p-4 text-center text-gray-500"> <td colSpan={5} className="p-6">
No career profiles yet <div className="text-center space-y-3">
<p className="text-gray-600">No career profiles yet.</p>
<div className="flex justify-center gap-2">
<Link to="/premium-onboarding" className="px-3 py-2 bg-gray-200 rounded">
Start Premium Onboarding
</Link>
<button
onClick={() => nav('/profile/careers/new/edit')}
className="px-3 py-2 bg-blue-600 text-white rounded"
>
+ Create first profile
</button>
</div>
</div>
</td> </td>
</tr> </tr>
)} )}

View File

@ -345,7 +345,6 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
// Basic states // Basic states
const [userProfile, setUserProfile] = useState(null); const [userProfile, setUserProfile] = useState(null);
const [financialProfile, setFinancialProfile] = useState(null); const [financialProfile, setFinancialProfile] = useState(null);
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
const [existingCareerProfiles, setExistingCareerProfiles] = useState([]); const [existingCareerProfiles, setExistingCareerProfiles] = useState([]);
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null); const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
const [careerProfileId, setCareerProfileId] = useState(null); const [careerProfileId, setCareerProfileId] = useState(null);
@ -527,6 +526,7 @@ useEffect(() => {
if (location.state?.fromOnboarding) { if (location.state?.fromOnboarding) {
modalGuard.current.skip = true; // suppress once modalGuard.current.skip = true; // suppress once
window.history.replaceState({}, '', location.pathname); window.history.replaceState({}, '', location.pathname);
sessionStorage.removeItem('suppressOnboardingGuard');
} }
}, [location.state, location.pathname]); }, [location.state, location.pathname]);
@ -535,7 +535,7 @@ useEffect(() => {
* ------------------------------------------------------------*/ * ------------------------------------------------------------*/
useEffect(() => { useEffect(() => {
(async () => { (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')) { if (up.ok && (up.headers.get('content-type')||'').includes('application/json')) {
setUserProfile(await up.json()); setUserProfile(await up.json());
} }
@ -691,20 +691,51 @@ useEffect(() => {
} }
}, [recommendations]); }, [recommendations]);
// ─────────────────────────────────────────────────────────────
// 2) load JSON => masterCareerRatings // Resolve SOC without shipping any bulk JSON to the browser
useEffect(() => { // Order: scenarioRow.soc_code → selectedCareer.code → resolve by title
(async () => { // ─────────────────────────────────────────────────────────────
try { const resolveSoc = useCallback(async () => {
const { data } = await api.get('/api/data/careers-with-ratings'); // 1) scenarioRow already has it?
setMasterCareerRatings(data || []); const fromScenario = scenarioRow?.soc_code || scenarioRow?.socCode;
} catch (err) { if (fromScenario) {
console.error('Error loading career ratings via API =>', err); setFullSocCode(fromScenario);
setMasterCareerRatings([]); 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 users career-profiles // 3) fetch users career-profiles
// utilities you already have in this file // utilities you already have in this file
@ -889,27 +920,6 @@ const refetchScenario = useCallback(async () => {
if (r.ok) setScenarioRow(await r.json()); if (r.ok) setScenarioRow(await r.json());
}, [careerProfileId]); }, [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(() => { useEffect(() => {
if (!fullSocCode || !scenarioRow || scenarioRow.riskLevel) return; if (!fullSocCode || !scenarioRow || scenarioRow.riskLevel) return;
(async () => { (async () => {
@ -931,6 +941,8 @@ const refetchScenario = useCallback(async () => {
})(); })();
}, [fullSocCode, scenarioRow]); }, [fullSocCode, scenarioRow]);
useEffect(() => { resolveSoc(); }, [resolveSoc]);
async function fetchAiRisk(socCode, careerName, description, tasks) { async function fetchAiRisk(socCode, careerName, description, tasks) {
let aiRisk = null; let aiRisk = null;

View File

@ -51,14 +51,37 @@ export default function CollegeProfileList() {
}, []); }, []);
/* ───────── delete helper ───────── */ /* ───────── delete helper ───────── */
async function handleDelete(id) { async function handleDelete(row) {
if (!window.confirm("Delete this college plan?")) return; if (!window.confirm("Delete this college plan?")) return;
try { try {
const res = await authFetch(`/api/premium/college-profile/${id}`, { const res = await authFetch(`/api/premium/college-profile/by-fields`, {
method: "DELETE", 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}`); 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||'')
)
));
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||'')
)
));
} catch (err) { } catch (err) {
console.error("Delete failed:", err); console.error("Delete failed:", err);
alert("Could not delete see console."); alert("Could not delete see console.");
@ -88,8 +111,10 @@ export default function CollegeProfileList() {
loading={loadingCareers} loading={loadingCareers}
authFetch={authFetch} authFetch={authFetch}
onChange={(careerObj) => { onChange={(careerObj) => {
if (!careerObj?.id) return; if (!careerObj) return;
navigate(`/profile/college/${careerObj.id}/new`); const title = careerObj.scenario_title || careerObj.career_name || '';
const start = careerObj.start_date || '';
navigate(`/profile/college/new?career=${encodeURIComponent(title)}&start=${encodeURIComponent(start)}`);
}} }}
/> />
<div className="mt-2 text-right"> <div className="mt-2 text-right">
@ -118,20 +143,18 @@ export default function CollegeProfileList() {
<tbody> <tbody>
{rows.map((r) => ( {rows.map((r) => (
<tr key={r.id} className="border-t"> <tr key={`${r.career_title}|${r.selected_school}|${r.selected_program}|${r.created_at}`} className="border-t">
<td className="p-2">{r.career_title}</td> <td className="p-2">{r.career_title}</td>
<td className="p-2">{r.selected_school}</td> <td className="p-2">{r.selected_school}</td>
<td className="p-2">{r.selected_program}</td> <td className="p-2">{r.selected_program}</td>
<td className="p-2">{r.created_at?.slice(0, 10)}</td> <td className="p-2">{r.created_at?.slice(0, 10)}</td>
<td className="p-2 space-x-2 whitespace-nowrap"> <td className="p-2 space-x-2 whitespace-nowrap">
<Link <Link
to={`/profile/college/${r.career_profile_id}/${r.id}`} to={`/profile/college/${encodeURIComponent(r.career_profile_id)}/edit`}
className="underline text-blue-600" className="underline text-blue-600"
> >edit</Link>
edit
</Link>
<button <button
onClick={() => handleDelete(r.id)} onClick={() => handleDelete(r)}
className="underline text-red-600" className="underline text-red-600"
> >
delete delete

View File

@ -190,6 +190,7 @@ const handleSelectSchool = async (school) => {
const selected_school = school?.INSTNM || ''; const selected_school = school?.INSTNM || '';
const selected_program = (school?.CIPDESC || '').replace(/\.\s*$/, ''); const selected_program = (school?.CIPDESC || '').replace(/\.\s*$/, '');
const program_type = school?.CREDDESC || ''; const program_type = school?.CREDDESC || '';
const unit_id = school?.UNITID || '';
// 2) merge into the cookie-backed draft (dont clobber existing sections) // 2) merge into the cookie-backed draft (dont clobber existing sections)
let draft = null; let draft = null;
@ -197,17 +198,17 @@ const handleSelectSchool = async (school) => {
const existing = draft?.data || {}; const existing = draft?.data || {};
await saveDraft({ await saveDraft({
id: draft?.id || null, step: 0,
step: draft?.step ?? 0, data: {
careerData: existing.careerData || {},
financialData: existing.financialData || {},
collegeData: { collegeData: {
...(existing.collegeData || {}),
selected_school, selected_school,
selected_program, selected_program,
program_type, program_type,
}, unit_id,
}); }
}
});
// 3) navigate (state is optional now that draft persists) // 3) navigate (state is optional now that draft persists)
navigate('/career-roadmap', { navigate('/career-roadmap', {
@ -218,7 +219,7 @@ const handleSelectSchool = async (school) => {
INSTNM: school.INSTNM, INSTNM: school.INSTNM,
CIPDESC: selected_program, CIPDESC: selected_program,
CREDDESC: program_type, CREDDESC: program_type,
UNITID: school.UNITID ?? null, UNITID: unit_id
}, },
}, },
}, },
@ -292,7 +293,7 @@ useEffect(() => {
useEffect(() => { useEffect(() => {
async function loadUserProfile() { async function loadUserProfile() {
try { try {
const { data } = await api.get('/api/user-profile'); const { data } = await api.get('/api/user-profile?fields=zipcode,area');
setUserZip(data.zipcode || ''); setUserZip(data.zipcode || '');
setUserState(data.state || ''); setUserState(data.state || '');
} catch (err) { } catch (err) {

View File

@ -64,7 +64,7 @@ const InterestInventory = () => {
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
try { try {
const res = await authFetch('/api/user-profile', { method: 'GET' }); const res = await authFetch('/api/user-profile?fields=interest_inventory_answers', { method: 'GET' });
if (!res || !res.ok) throw new Error('Failed to fetch user profile'); if (!res || !res.ok) throw new Error('Failed to fetch user profile');
const data = await res.json(); const data = await res.json();
setUserProfile(data); setUserProfile(data);
@ -137,16 +137,7 @@ const InterestInventory = () => {
await authFetch('/api/user-profile', { await authFetch('/api/user-profile', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ interest_inventory_answers: answers }),
firstName: userProfile?.firstname,
lastName: userProfile?.lastname,
email: userProfile?.email,
zipCode: userProfile?.zipcode,
state: userProfile?.state,
area: userProfile?.area,
careerSituation: userProfile?.career_situation || null,
interest_inventory_answers: answers,
}),
}); });
} catch (err) { } catch (err) {
console.error('Error saving answers to user profile:', err.message); console.error('Error saving answers to user profile:', err.message);

View File

@ -1,5 +1,5 @@
// CareerOnboarding.js // CareerOnboarding.js
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { saveDraft, clearDraft, loadDraft } from '../../utils/onboardingDraftApi.js'; import { saveDraft, clearDraft, loadDraft } from '../../utils/onboardingDraftApi.js';
@ -18,9 +18,10 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
return JSON.parse(localStorage.getItem('selectedCareer') || 'null'); return JSON.parse(localStorage.getItem('selectedCareer') || 'null');
} catch { return null; } } catch { return null; }
}); });
const [currentlyWorking, setCurrentlyWorking] = useState(''); const [currentlyWorking, setCurrentlyWorking] = useState(data.currently_working || '');
const [collegeStatus, setCollegeStatus] = useState(''); const [collegeStatus, setCollegeStatus] = useState(data.college_enrollment_status || '');
const [showFinPrompt, setShowFinPrompt] = useState(false); const [showFinPrompt, setShowFinPrompt] = useState(false);
const finPromptShownRef = useRef(false);
/* ── 2. derived helpers ───────────────────────────────────── */ /* ── 2. derived helpers ───────────────────────────────────── */
const selectedCareerTitle = careerObj?.title || ''; const selectedCareerTitle = careerObj?.title || '';
@ -39,21 +40,59 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
if (!navCareerObj?.title) return; if (!navCareerObj?.title) return;
setCareerObj(navCareerObj); setCareerObj(navCareerObj);
localStorage.setItem('selectedCareer', JSON.stringify(navCareerObj)); localStorage.setItem('selectedCareer', JSON.stringify({
title: navCareerObj.title,
soc_code: navCareerObj.soc_code || navCareerObj.code || ''
}));
setData(prev => ({ setData(prev => ({
...prev, ...prev,
career_name : navCareerObj.title, career_name : navCareerObj.title,
soc_code : navCareerObj.soc_code || '' soc_code : navCareerObj.soc_code || navCareerObj.code || navCareerObj.socCode || ''
})); }));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [navCareerObj]); // ← run once per navigation change }, [navCareerObj]); // ← run once per navigation change
/* Hydrate from saved draft on mount (tolerant of shapes) */
useEffect(() => {
(async () => {
try {
const d = await loadDraft().catch(() => null);
const bag = d?.data || d || {};
const cData = bag.careerData || bag; // tolerate {careerData:{...}} or flattened
const cw = cData.currently_working;
const cs = cData.college_enrollment_status;
const sf = cData.skipFinancialStep;
if (cw) {
setCurrentlyWorking(cw);
setData(prev => ({ ...prev, currently_working: cw }));
}
if (cs) {
setCollegeStatus(cs);
setData(prev => ({ ...prev, college_enrollment_status: cs }));
}
if (typeof sf === 'boolean') {
setData(prev => ({ ...prev, skipFinancialStep: sf }));
}
} catch {}
})();
}, [setData]);
/* Compute when to show the Skip Financial prompt */
useEffect(() => {
const inCol = ['currently_enrolled','prospective_student'].includes(collegeStatus);
const shouldShow =
currentlyWorking === 'no' &&
inCol &&
typeof data.skipFinancialStep === 'undefined';
setShowFinPrompt(shouldShow);
}, [currentlyWorking, collegeStatus, skipFin, data.skipFinancialStep]);
// Called whenever other <inputs> change // Called whenever other <inputs> change
const handleChange = (e) => { const handleChange = (e) => {
setData(prev => ({ ...prev, [e.target.name]: e.target.value })); setData(prev => ({ ...prev, [e.target.name]: e.target.value }));
const k = e.target.name; const k = e.target.name;
if (['status','start_date','career_goals'].includes(k)) { if (['status','start_date'].includes(k)) {
saveDraft({ careerData: { [k]: e.target.value } }).catch(() => {}); saveDraft({ careerData: { [k]: e.target.value } }).catch(() => {});
} }
}; };
@ -168,10 +207,9 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
value={collegeStatus} value={collegeStatus}
onChange={(e) => { onChange={(e) => {
const val = e.target.value; const val = e.target.value;
setCurrentlyWorking(val); setCollegeStatus(val);
setData(prev => ({ ...prev, currently_working: val })); setData(prev => ({ ...prev, college_enrollment_status: val }));
// persist immediately saveDraft({ careerData: { college_enrollment_status: val } }).catch(() => {});
saveDraft({ careerData: { currently_working: val } }).catch(() => {});
}} }}
required required
className="w-full border rounded p-2" className="w-full border rounded p-2"
@ -195,6 +233,7 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
onClick={() => { onClick={() => {
// user explicitly wants to enter financials now // user explicitly wants to enter financials now
setData(prev => ({ ...prev, skipFinancialStep: false })); setData(prev => ({ ...prev, skipFinancialStep: false }));
saveDraft({ careerData: { skipFinancialStep: false } }).catch(() => {});
setShowFinPrompt(false); setShowFinPrompt(false);
}} }}
> >
@ -206,7 +245,7 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
onClick={() => { onClick={() => {
/* mark intent to skip the finance step */ /* mark intent to skip the finance step */
setData(prev => ({ ...prev, skipFinancialStep: true })); setData(prev => ({ ...prev, skipFinancialStep: true }));
saveDraft({ careerData: { skipFinancialStep: true } }).catch(() => {});
setShowFinPrompt(false); // hide the prompt, stay on page setShowFinPrompt(false); // hide the prompt, stay on page
}} }}
> >
@ -222,6 +261,12 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
name="career_goals" name="career_goals"
value={data.career_goals || ''} value={data.career_goals || ''}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = e.target.value;
if (v !== (data.career_goals || '')) {
saveDraft({ careerData: { career_goals: v } }).catch(() => {});
}
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
placeholder="Tell us about your goals, aspirations, or next steps..." placeholder="Tell us about your goals, aspirations, or next steps..."
/> />

View File

@ -26,15 +26,23 @@ const [expectedGraduation, setExpectedGraduation] = useState(data.expected_g
location.state?.premiumOnboardingState?.selectedSchool; location.state?.premiumOnboardingState?.selectedSchool;
const [selectedSchool, setSelectedSchool] = useState(() => { const [selectedSchool, setSelectedSchool] = useState(() => {
if (navSelectedSchoolObj && typeof navSelectedSchoolObj === 'object') { if (navSelectedSchoolObj) {
return { INSTNM: navSelectedSchoolObj.INSTNM, const name = toSchoolName(navSelectedSchoolObj);
CIPDESC: navSelectedSchoolObj.CIPDESC || '', return {
CREDDESC: navSelectedSchoolObj.CREDDESC || '' }; INSTNM: name,
CIPDESC: navSelectedSchoolObj.CIPDESC || navSelectedSchoolObj.program || '',
CREDDESC: navSelectedSchoolObj.CREDDESC || navSelectedSchoolObj.programType || '',
UNITID: navSelectedSchoolObj.UNITID || navSelectedSchoolObj.unitId || null
};
} }
if (data.selected_school) { if (data.selected_school) {
return { INSTNM: data.selected_school, return {
INSTNM: data.selected_school,
CIPDESC: data.selected_program || '', CIPDESC: data.selected_program || '',
CREDDESC: data.program_type || '' }; CREDDESC: data.program_type || '',
UNITID: null
};
} }
return null; return null;
}); });
@ -58,9 +66,9 @@ function toSchoolName(objOrStr) {
// Destructure parent data // Destructure parent data
const { const {
college_enrollment_status = '', college_enrollment_status = '',
selected_school = '', selected_school: top_selected_school = '',
selected_program = '', selected_program: top_selected_program = '',
program_type = '', program_type: top_program_type = '',
academic_calendar = 'semester', academic_calendar = 'semester',
annual_financial_aid = '', annual_financial_aid = '',
is_online = false, is_online = false,
@ -80,6 +88,10 @@ function toSchoolName(objOrStr) {
tuition_paid = '', tuition_paid = '',
} = data; } = data;
const selected_school = top_selected_school || (data.collegeData?.selected_school || '');
const selected_program = top_selected_program || (data.collegeData?.selected_program || '');
const program_type = top_program_type || (data.collegeData?.program_type || '');
// Local states for auto/manual logic on tuition & program length // Local states for auto/manual logic on tuition & program length
const [manualTuition, setManualTuition] = useState(''); const [manualTuition, setManualTuition] = useState('');
const [autoTuition, setAutoTuition] = useState(0); const [autoTuition, setAutoTuition] = useState(0);
@ -100,40 +112,93 @@ function toSchoolName(objOrStr) {
} }
}, [selectedSchool, setData]); }, [selectedSchool, setData]);
// Backfill from cookie-backed draft if props aren't populated yet // If a UNITID came in (from EducationalPrograms nav or draft), use it for auto-tuition
useEffect(() => { useEffect(() => {
// if props already have values, do nothing if (selectedSchool?.UNITID && !selectedUnitId) {
if (data?.selected_school || data?.selected_program || data?.program_type) return; setSelectedUnitId(selectedSchool.UNITID);
}
}, [selectedSchool, selectedUnitId]);
// If draft already had a unit_id, use it (fires auto-tuition)
useEffect(() => {
const uid = data.collegeData?.unit_id || null;
if (!selectedUnitId && uid) setSelectedUnitId(uid);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data.collegeData?.unit_id, selectedUnitId]);
// One-shot hydrate from EducationalProgramsPage when state is present (no status override)
useEffect(() => {
if (!navSelectedSchoolObj) return;
const name = toSchoolName(navSelectedSchoolObj);
const prog = navSelectedSchoolObj.CIPDESC || navSelectedSchoolObj.program || '';
const ptype = navSelectedSchoolObj.CREDDESC || navSelectedSchoolObj.programType || '';
const uid = navSelectedSchoolObj.UNITID || navSelectedSchoolObj.unitId || null;
setSelectedSchool({ INSTNM: name, CIPDESC: prog, CREDDESC: ptype, UNITID: uid });
if (uid) setSelectedUnitId(uid);
setData(prev => ({
...prev,
selected_school : name,
selected_program: prog || prev.selected_program || '',
program_type : ptype || prev.program_type || ''
}));
saveDraft({
collegeData: {
selected_school: name,
selected_program: prog,
program_type: ptype,
unit_id: uid
}
}).catch(()=>{});
// clear one-shot router state
window.history.replaceState({}, '', location.pathname);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [navSelectedSchoolObj]);
// Backfill from draft: merge-on-empty (never overwrite user edits)
useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
let draft; let draft;
try { draft = await loadDraft(); } catch { draft = null; } try { draft = await loadDraft(); } catch { draft = null; }
const cd = draft?.data?.collegeData; const cd = draft?.data?.collegeData;
if (!cd) return; if (!cd || cancelled) return;
if (cancelled) return;
// 1) write into parent data (so inputs prefill)
setData(prev => ({ setData(prev => ({
...prev, ...prev,
selected_school : cd.selected_school ?? prev.selected_school ?? '', selected_school : prev.selected_school || cd.selected_school || '',
selected_program: cd.selected_program ?? prev.selected_program ?? '', selected_program: prev.selected_program || cd.selected_program || '',
program_type : cd.program_type ?? prev.program_type ?? '' program_type : prev.program_type || cd.program_type || ''
})); }));
// 2) set local selectedSchool object (triggers your selectedSchool→data effect too) // Reflect into local selectedSchool (keeps your existing flow intact)
setSelectedSchool({ setSelectedSchool(prev => ({
INSTNM : cd.selected_school || '', INSTNM : (prev?.INSTNM ?? '') || cd.selected_school || '',
CIPDESC : cd.selected_program || '', CIPDESC : (prev?.CIPDESC ?? '') || cd.selected_program || '',
CREDDESC: cd.program_type || '' CREDDESC: (prev?.CREDDESC?? '') || cd.program_type || '',
}); UNITID : (prev?.UNITID ?? null) || cd.unit_id || null
}));
if (!selectedUnitId && cd.unit_id) setSelectedUnitId(cd.unit_id);
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
// run once on mount; we don't want to fight subsequent user edits
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// If status is missing (e.g., resuming via draft), hydrate it from draft.careerData only
useEffect(() => {
(async () => {
if (college_enrollment_status) return;
try {
const d = await loadDraft();
const cs = d?.data?.careerData?.college_enrollment_status;
if (cs) setData(prev => ({ ...prev, college_enrollment_status: cs }));
} catch {}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => { useEffect(() => {
if (data.expected_graduation && !expectedGraduation) if (data.expected_graduation && !expectedGraduation)
setExpectedGraduation(data.expected_graduation); setExpectedGraduation(data.expected_graduation);
@ -237,7 +302,7 @@ useEffect(() => {
setSchoolSuggestions([]); setSchoolSuggestions([]);
setProgramSuggestions([]); setProgramSuggestions([]);
setAvailableProgramTypes([]); setAvailableProgramTypes([]);
saveDraft({ collegeData: { selected_school: name } }).catch(() => {}); saveDraft({ collegeData: { selected_school: name, unit_id: uid } }).catch(() => {});
}; };
// Program // Program
@ -344,15 +409,15 @@ useEffect(() => {
]); ]);
useEffect(() => { useEffect(() => {
const hasSchool = !!data.selected_school; const hasSchool = !!selected_school;
const hasAnyProgram = !!data.selected_program || !!data.program_type; const hasAnyProgram = !!selected_program || !!program_type;
if (!hasSchool && !hasAnyProgram) return; if (!hasSchool && !hasAnyProgram) return;
setSelectedSchool(prev => { setSelectedSchool(prev => {
const next = { const next = {
INSTNM : data.selected_school || '', INSTNM : selected_school || '',
CIPDESC : data.selected_program || '', CIPDESC : selected_program || '',
CREDDESC: data.program_type || '' CREDDESC: program_type || ''
}; };
// avoid useless state churn // avoid useless state churn
if (prev && if (prev &&
@ -361,7 +426,7 @@ useEffect(() => {
prev.CREDDESC=== next.CREDDESC) return prev; prev.CREDDESC=== next.CREDDESC) return prev;
return next; return next;
}); });
}, [data.selected_school, data.selected_program, data.program_type]); }, [selected_school, selected_program, program_type]);
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Whenever the user changes enrollmentDate OR programLength */ /* Whenever the user changes enrollmentDate OR programLength */
@ -415,6 +480,12 @@ useEffect(() => {
program_length: chosenProgramLength program_length: chosenProgramLength
})); }));
saveDraft({ collegeData: {
tuition: Number(chosenTuition) || 0,
program_length: Number(chosenProgramLength) || 0,
expected_graduation: expectedGraduation || ''
}}).catch(()=>{});
nextStep(); nextStep();
}; };
@ -456,8 +527,10 @@ const ready =
name="is_in_district" name="is_in_district"
checked={is_in_district} checked={is_in_district}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
onBlur={()=>saveDraft({collegeData:{is_in_district}}).catch(()=>{})}
className="h-4 w-4" className="h-4 w-4"
/> />
<label className="font-medium">In District? {infoIcon("Used by Community Colleges usually - local discounts")}</label> <label className="font-medium">In District? {infoIcon("Used by Community Colleges usually - local discounts")}</label>
</div> </div>
@ -467,6 +540,7 @@ const ready =
name="is_in_state" name="is_in_state"
checked={is_in_state} checked={is_in_state}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
onBlur={()=>saveDraft({collegeData:{is_in_state}}).catch(()=>{})}
className="h-4 w-4" className="h-4 w-4"
/> />
<label className="font-medium">In State Tuition? {infoIcon("Students can qualify for discounted tuition if they are a resident of the same state as the college - private institutions rarely have this discounts")}</label> <label className="font-medium">In State Tuition? {infoIcon("Students can qualify for discounted tuition if they are a resident of the same state as the college - private institutions rarely have this discounts")}</label>
@ -478,6 +552,7 @@ const ready =
name="is_online" name="is_online"
checked={is_online} checked={is_online}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
onBlur={()=>saveDraft({collegeData:{is_online}}).catch(()=>{})}
className="h-4 w-4" className="h-4 w-4"
/> />
<label className="font-medium">Program is Fully Online {infoIcon("Tuition rates for fully online programs are usually different than traditional")}</label> <label className="font-medium">Program is Fully Online {infoIcon("Tuition rates for fully online programs are usually different than traditional")}</label>
@ -489,6 +564,7 @@ const ready =
name="loan_deferral_until_graduation" name="loan_deferral_until_graduation"
checked={loan_deferral_until_graduation} checked={loan_deferral_until_graduation}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
onBlur={()=>saveDraft({collegeData:{loan_deferral_until_graduation}}).catch(()=>{})}
className="h-4 w-4" className="h-4 w-4"
/> />
<label className="font-medium">Defer Loan Payments until Graduation? {infoIcon("You can delay paying tuition loans while still in college, but they will still accrue interest during this time")}</label> <label className="font-medium">Defer Loan Payments until Graduation? {infoIcon("You can delay paying tuition loans while still in college, but they will still accrue interest during this time")}</label>
@ -590,6 +666,7 @@ const ready =
value={credit_hours_required} value={credit_hours_required}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="e.g. 120" placeholder="e.g. 120"
onBlur={(e)=>saveDraft({collegeData:{credit_hours_required: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -603,6 +680,7 @@ const ready =
value={credit_hours_per_year} value={credit_hours_per_year}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="e.g. 24" 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" className="w-full border rounded p-2"
/> />
</div> </div>
@ -629,6 +707,7 @@ const ready =
value={annual_financial_aid} value={annual_financial_aid}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="e.g. 2000" placeholder="e.g. 2000"
onBlur={(e)=>saveDraft({collegeData:{annual_financial_aid: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
<button <button
@ -649,6 +728,7 @@ const ready =
value={existing_college_debt} value={existing_college_debt}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="e.g. 2000" placeholder="e.g. 2000"
onBlur={(e)=>saveDraft({collegeData:{existing_college_debt: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -680,6 +760,7 @@ const ready =
value={hours_completed} value={hours_completed}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="Credit hours done" placeholder="Credit hours done"
onBlur={(e)=>saveDraft({collegeData:{hours_completed: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -700,6 +781,7 @@ const ready =
setEnrollmentDate(e.target.value); setEnrollmentDate(e.target.value);
setData(p => ({ ...p, enrollment_date: e.target.value })); setData(p => ({ ...p, enrollment_date: e.target.value }));
}} }}
onBlur={(e)=>saveDraft({collegeData:{enrollment_date: e.target.value || ''}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
required required
/> />
@ -726,6 +808,7 @@ const ready =
setExpectedGraduation(e.target.value); setExpectedGraduation(e.target.value);
setData(p => ({ ...p, expected_graduation: e.target.value })); setData(p => ({ ...p, expected_graduation: e.target.value }));
}} }}
onBlur={(e)=>saveDraft({collegeData:{expected_graduation: e.target.value || ''}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
required required
/> />
@ -741,6 +824,7 @@ const ready =
value={interest_rate} value={interest_rate}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="e.g. 5.5" placeholder="e.g. 5.5"
onBlur={(e)=>saveDraft({collegeData:{interest_rate: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -753,6 +837,7 @@ const ready =
value={loan_term} value={loan_term}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="e.g. 10" placeholder="e.g. 10"
onBlur={(e)=>saveDraft({collegeData:{loan_term: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -765,6 +850,7 @@ const ready =
value={extra_payment} value={extra_payment}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="Optional" placeholder="Optional"
onBlur={(e)=>saveDraft({collegeData:{extra_payment: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -777,6 +863,7 @@ const ready =
value={expected_salary} value={expected_salary}
onChange={handleParentFieldChange} onChange={handleParentFieldChange}
placeholder="e.g. 65000" placeholder="e.g. 65000"
onBlur={(e)=>saveDraft({collegeData:{expected_salary: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -809,10 +896,8 @@ const ready =
<Modal onClose={() => setShowAidWizard(false)}> <Modal onClose={() => setShowAidWizard(false)}>
<FinancialAidWizard <FinancialAidWizard
onAidEstimated={(estimate) => { onAidEstimated={(estimate) => {
setData(prev => ({ setData(prev => ({ ...prev, annual_financial_aid: estimate }));
...prev, saveDraft({ collegeData: { annual_financial_aid: estimate } }).catch(() => {});
annual_financial_aid: estimate
}));
}} }}
onClose={() => setShowAidWizard(false)} onClose={() => setShowAidWizard(false)}
/> />

View File

@ -74,20 +74,24 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
} }
}).catch(() => {}); }).catch(() => {});
} else { } else {
setData(prevData => ({ ...prevData, [name]: val })); setData(prev => ({ ...prev, [name]: val }));
saveDraft({ saveDraft({
financialData: { financialData: {
extra_cash_emergency_pct: val, [name]: val,
extra_cash_retirement_pct: 100 - val extra_cash_emergency_pct: Number.isFinite(extra_cash_emergency_pct) ? extra_cash_emergency_pct : 50,
} extra_cash_retirement_pct: Number.isFinite(extra_cash_retirement_pct) ? extra_cash_retirement_pct : 50
}).catch(() => {});
} }
}).catch(()=>{});
}
}; };
const handleSubmit = () => { const handleSubmit = () => {
// Move to next step saveDraft({ financialData: {
extra_cash_emergency_pct: Number.isFinite(extra_cash_emergency_pct) ? extra_cash_emergency_pct : 50,
extra_cash_retirement_pct: Number.isFinite(extra_cash_retirement_pct) ? extra_cash_retirement_pct : 50
}}).catch(()=>{});
nextStep(); nextStep();
}; };
return ( return (
<div className="max-w-md mx-auto p-6 space-y-6"> <div className="max-w-md mx-auto p-6 space-y-6">
@ -105,6 +109,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="e.g. 110000" placeholder="e.g. 110000"
value={current_salary || ''} value={current_salary || ''}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { current_salary: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -119,6 +127,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="e.g. 2400" placeholder="e.g. 2400"
value={additional_income || ''} value={additional_income || ''}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { additional_income: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -136,6 +148,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
name="monthly_expenses" name="monthly_expenses"
value={monthly_expenses} value={monthly_expenses}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { monthly_expenses: v } }).catch(() => {});
}}
placeholder="e.g. 1500" placeholder="e.g. 1500"
/> />
<Button <Button
@ -156,6 +172,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="e.g. 500" placeholder="e.g. 500"
value={monthly_debt_payments || ''} value={monthly_debt_payments || ''}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { monthly_debt_payments: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -170,6 +190,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="e.g. 50000" placeholder="e.g. 50000"
value={retirement_savings || ''} value={retirement_savings || ''}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { retirement_savings: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -184,6 +208,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="e.g. 300" placeholder="e.g. 300"
value={retirement_contribution || ''} value={retirement_contribution || ''}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { retirement_contribution: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -198,6 +226,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="e.g. 10000" placeholder="e.g. 10000"
value={emergency_fund || ''} value={emergency_fund || ''}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { emergency_fund: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -212,6 +244,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="e.g. 300" placeholder="e.g. 300"
value={emergency_contribution || ''} value={emergency_contribution || ''}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { emergency_contribution: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -232,6 +268,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="% to Emergency Savings (e.g., 30)" placeholder="% to Emergency Savings (e.g., 30)"
value={extra_cash_emergency_pct} value={extra_cash_emergency_pct}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { extra_cash_emergency_pct: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -244,6 +284,10 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
placeholder="% to Retirement Savings (e.g., 70)" placeholder="% to Retirement Savings (e.g., 70)"
value={extra_cash_retirement_pct} value={extra_cash_retirement_pct}
onChange={handleChange} onChange={handleChange}
onBlur={(e) => {
const v = parseFloat(e.target.value) || 0;
saveDraft({ financialData: { extra_cash_retirement_pct: v } }).catch(() => {});
}}
className="w-full border rounded p-2" className="w-full border rounded p-2"
/> />
</div> </div>
@ -257,7 +301,7 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
Previous: Career Previous: Career
</button> </button>
<button <button
onClick={nextStep} onClick={handleSubmit}
className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded" className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded"
> >
Next: College Next: College

View File

@ -8,6 +8,7 @@ import CollegeOnboarding from './CollegeOnboarding.js';
import ReviewPage from './ReviewPage.js'; import ReviewPage from './ReviewPage.js';
import { loadDraft, saveDraft, clearDraft } from '../../utils/onboardingDraftApi.js'; import { loadDraft, saveDraft, clearDraft } from '../../utils/onboardingDraftApi.js';
import authFetch from '../../utils/authFetch.js'; import authFetch from '../../utils/authFetch.js';
import { isOnboardingInProgress } from '../../utils/onboardingGuard.js';
const POINTER_KEY = 'premiumOnboardingPointer'; const POINTER_KEY = 'premiumOnboardingPointer';
@ -28,30 +29,6 @@ export default function OnboardingContainer() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
// A) migrate any old local blob (once), then delete it // A) migrate any old local blob (once), then delete it
const oldRaw = localStorage.getItem('premiumOnboardingState'); // legacy
if (oldRaw) {
try {
const legacy = JSON.parse(oldRaw);
const draft = await saveDraft({
id: null,
step: legacy.step ?? 0,
careerData: legacy.careerData || {},
financialData: legacy.financialData || {},
collegeData: legacy.collegeData || {}
});
ptrRef.current = {
id: draft.id,
step: draft.step,
skipFin: !!legacy?.careerData?.skipFinancialStep,
selectedCareer: JSON.parse(localStorage.getItem('selectedCareer') || 'null')
};
localStorage.removeItem('premiumOnboardingState'); // nuke
localStorage.setItem(POINTER_KEY, JSON.stringify(ptrRef.current));
} catch (e) {
console.warn('Legacy migration failed; wiping local blob.', e);
localStorage.removeItem('premiumOnboardingState');
}
}
// B) load pointer // B) load pointer
try { try {
@ -73,6 +50,14 @@ export default function OnboardingContainer() {
setCareerData(d.careerData || {}); setCareerData(d.careerData || {});
setFinancialData(d.financialData || {}); setFinancialData(d.financialData || {});
setCollegeData(d.collegeData || {}); setCollegeData(d.collegeData || {});
// 🔒 Prime autosave baselines so we DON'T post empty slices
try {
prevCareerJsonRef.current = JSON.stringify(d.careerData || {});
prevFinancialJsonRef.current = JSON.stringify(d.financialData || {});
prevCollegeJsonRef.current = JSON.stringify(d.collegeData || {});
} catch {}
} else { } else {
// no server draft yet: seed with minimal data from pointer/local selectedCareer // no server draft yet: seed with minimal data from pointer/local selectedCareer
setStep(ptrRef.current.step || 0); setStep(ptrRef.current.step || 0);
@ -96,40 +81,78 @@ export default function OnboardingContainer() {
selected_program: typeof navSchool === 'object' ? (navSchool.CIPDESC || cd.selected_program || '') : cd.selected_program, selected_program: typeof navSchool === 'object' ? (navSchool.CIPDESC || cd.selected_program || '') : cd.selected_program,
program_type : typeof navSchool === 'object' ? (navSchool.CREDDESC || cd.program_type || '') : cd.program_type, program_type : typeof navSchool === 'object' ? (navSchool.CREDDESC || cd.program_type || '') : cd.program_type,
})); }));
// keep baseline in sync so autosave doesn't blast empty collegeData
try {
const merged = {
...(draft?.data?.collegeData || {}),
selected_school : typeof navSchool === 'string' ? navSchool : (navSchool.INSTNM || ''),
selected_program: typeof navSchool === 'object' ? (navSchool.CIPDESC || '') : '',
program_type : typeof navSchool === 'object' ? (navSchool.CREDDESC || '') : '',
};
prevCollegeJsonRef.current = JSON.stringify(merged);
} catch {}
} }
setLoaded(true); setLoaded(true);
})(); })();
}, [location.state]); }, [location.state]);
// ---- 2) debounced autosave to server + pointer update ---------- // ---- 2) debounced autosave — send only changed slices ----------
const prevCareerJsonRef = useRef('');
const prevFinancialJsonRef = useRef('');
const prevCollegeJsonRef = useRef('');
useEffect(() => { useEffect(() => {
if (!loaded) return; if (!loaded) return;
const t = setTimeout(async () => { const t = setTimeout(async () => {
// persist server draft (all sensitive data) const cj = JSON.stringify(careerData);
const resp = await saveDraft({ const fj = JSON.stringify(financialData);
id: ptrRef.current.id, const col = JSON.stringify(collegeData);
step,
careerData, const nonEmpty = (s) => s && s !== '{}' && s !== 'null';
financialData,
collegeData const changedCareer = cj !== prevCareerJsonRef.current && nonEmpty(cj);
}); const changedFinancial = fj !== prevFinancialJsonRef.current && nonEmpty(fj);
// update pointer (safe) const changedCollege = col !== prevCollegeJsonRef.current && nonEmpty(col);
const somethingChanged = changedCareer || changedFinancial || changedCollege;
// Always update the local pointer, but DO NOT POST an empty data object.
const pointer = { const pointer = {
id: resp.id, id: ptrRef.current.id,
step, step,
skipFin: !!careerData.skipFinancialStep, skipFin: !!careerData.skipFinancialStep,
selectedCareer: (careerData.career_name || careerData.soc_code) selectedCareer: (careerData.career_name || careerData.soc_code)
? { title: careerData.career_name, soc_code: careerData.soc_code } ? { title: careerData.career_name, soc_code: careerData.soc_code }
: JSON.parse(localStorage.getItem('selectedCareer') || 'null') : JSON.parse(localStorage.getItem('selectedCareer') || 'null'),
}; };
ptrRef.current = pointer; ptrRef.current = pointer;
localStorage.setItem(POINTER_KEY, JSON.stringify(pointer)); localStorage.setItem(POINTER_KEY, JSON.stringify(pointer));
}, 400); // debounce
if (!somethingChanged) return; // ← prevent the `{ data:{} }` POST
// Build a payload that includes only changed slices
const payload = { id: ptrRef.current.id, step, data: {} };
if (changedCareer) payload.data.careerData = careerData;
if (changedFinancial) payload.data.financialData = financialData;
if (changedCollege) payload.data.collegeData = collegeData;
const resp = await saveDraft(payload);
// update baselines only for the slices we actually sent
if (changedCareer) prevCareerJsonRef.current = cj;
if (changedFinancial) prevFinancialJsonRef.current = fj;
if (changedCollege) prevCollegeJsonRef.current = col;
// keep pointer id in sync with server
ptrRef.current.id = resp.id;
}, 400);
return () => clearTimeout(t); return () => clearTimeout(t);
}, [loaded, step, careerData, financialData, collegeData]); }, [loaded, step, careerData, financialData, collegeData]);
// ---- nav helpers ------------------------------------------------ // ---- nav helpers ------------------------------------------------
const nextStep = () => setStep((s) => s + 1); const nextStep = () => setStep((s) => s + 1);
@ -190,7 +213,7 @@ export default function OnboardingContainer() {
// 🔐 cleanup: remove server draft + pointer // 🔐 cleanup: remove server draft + pointer
await clearDraft(); await clearDraft();
localStorage.removeItem(POINTER_KEY); localStorage.removeItem(POINTER_KEY);
sessionStorage.setItem('suppressOnboardingGuard', '1');
navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } }); navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } });
} catch (err) { } catch (err) {
console.error('Error in final submit =>', err); console.error('Error in final submit =>', err);

View File

@ -5,7 +5,7 @@ import * as safeLocal from '../utils/safeLocal.js';
function SignIn({ setIsAuthenticated, setUser }) { function SignIn({ setIsAuthenticated, setUser }) {
const navigate = useNavigate(); const navigate = useNavigate();
const { setFinancialProfile, setScenario } = useContext(ProfileCtx); const { setScenario } = useContext(ProfileCtx);
const usernameRef = useRef(''); const usernameRef = useRef('');
const passwordRef = useRef(''); const passwordRef = useRef('');
const [error, setError] = useState(''); const [error, setError] = useState('');
@ -55,20 +55,27 @@ function SignIn({ setIsAuthenticated, setUser }) {
const data = await resp.json(); const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to sign in'); if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
// Optional: keep allowlisted id if provided by API // Fetch ONLY the minimal fields needed for the landing UI
if (data.id) localStorage.setItem('id', data.id); // (requires server1 to honor ?fields=… — otherwise remove `fields=` until its enabled)
let minimalUser = { firstname: '' };
// Load user profile for app state; cookie is sent automatically try {
const profileRes = await fetch('/api/user-profile', { const minimalRes = await fetch('/api/user-profile?fields=firstname', {
credentials: 'include', credentials: 'include',
}); });
if (!profileRes.ok) throw new Error('Failed to load profile'); if (minimalRes.ok) {
const profile = await profileRes.json(); const j = await minimalRes.json();
minimalUser = {
firstname: j.firstname || '',
is_premium: j.is_premium ?? 0,
is_pro_premium: j.is_pro_premium ?? 0,
};
}
} catch (_) {}
setFinancialProfile(profile); // 3) Establish app auth state with minimal user info
setScenario(null); setScenario(null);
setIsAuthenticated(true); setIsAuthenticated(true);
setUser(data.user || null); setUser(minimalUser);
navigate('/signin-landing'); navigate('/signin-landing');
} catch (err) { } catch (err) {

View File

@ -9,9 +9,9 @@ function SignInLanding({ user }) {
Welcome to AptivaAI {user?.firstname}! Welcome to AptivaAI {user?.firstname}!
</h2> </h2>
<p className="mb-4"> <p className="mb-4">
At AptivaAI, we aim to arm you with all the knowledge and guidance we wish we had when making our own career decisions. Todays workplace is changing faster than ever, driven largely by AIbut our goal is to use that same technology to empower job seekers, not replace them. At AptivaAI, we aim to arm you with as much information as possible to make informed career decisions. Todays workplace is changing faster than ever, driven largely by AIbut our goal is to use that same technology to empower job seekers, not replace them.
We blend data-backed insights with human-centered design, giving you practical recommendations and real-world context so you stay in the drivers seat of your career. Whether youre planning your first step, enhancing your current role, or ready to pivot entirely, our platform keeps you in controlhelping you adapt, grow, and thrive on your own terms. We blend data-backed insights with human-centered design, enhanced -not driven by- AI. Giving you practical recommendations and real-world context so you stay in the drivers seat of your career. Whether youre planning your first step, advancing your current role, or ready to pivot entirely, our platform keeps you in controlhelping you adapt, grow, and thrive on your own terms.
</p> </p>
<ul className="list-disc ml-6 mb-4"> <ul className="list-disc ml-6 mb-4">
<li><strong>Planning:</strong> Just starting out? Looking for a different career that is a better fit? Explore options and figure out what careers match your interests and skills.</li> <li><strong>Planning:</strong> Just starting out? Looking for a different career that is a better fit? Explore options and figure out what careers match your interests and skills.</li>
@ -24,15 +24,15 @@ We blend data-backed insights with human-centered design, giving you practical r
</p> </p>
<div className="space-x-2"> <div className="space-x-2">
<Link to="/planning" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> <Link to="/planning" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Go to Planning Go to Exploring
</Link> </Link>
<Link to="/preparing" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> <Link to="/preparing" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Go to Preparing Go to Preparing
</Link> </Link>
<Link to="/enhancing" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> <Link to="/enhancing" className="inline-block px-4 py-2 bg-green-600 text-white rounded hover:bg-blue-700">
Go to Enhancing Go to Enhancing
</Link> </Link>
<Link to="/retirement" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"> <Link to="/retirement" className="inline-block px-4 py-2 bg-green-600 text-white rounded hover:bg-blue-700">
Go to Retirement Go to Retirement
</Link> </Link>
</div> </div>

View File

@ -1,8 +1,9 @@
// src/components/SupportModal.js
import React, { useState } from 'react'; import React, { useState } from 'react';
import authFetch from '../utils/authFetch.js'; import authFetch from '../utils/authFetch.js';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
export default function SupportModal({ open, onClose, userEmail }) { export default function SupportModal({ open, onClose }) {
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
const [category, setCategory] = useState('general'); const [category, setCategory] = useState('general');
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
@ -15,29 +16,30 @@ export default function SupportModal({ open, onClose, userEmail }) {
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
if (!message || message.trim().length < 5) { if (!message || message.trim().length < 5) {
setError('Please enter at least 5 characters.'); setError('Please enter at least 5 characters.');
setOk(false); setOk(false);
return; return;
} }
setSending(true); setSending(true);
try { try {
// Do NOT include email; server resolves reply-to from the session/DB
const res = await authFetch('/api/support', { const res = await authFetch('/api/support', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: userEmail, subject, category, message }) body: JSON.stringify({ subject, category, message })
}); });
if (!res) {
// authFetch returns null on 401/403 if (!res) throw new Error('Your session expired. Please sign in again.');
throw new Error('Your session expired. Please sign in again.');
}
if (!res.ok) { if (!res.ok) {
const j = await res.json().catch(() => ({})); const j = await res.json().catch(() => ({}));
throw new Error(j.error || `Request failed (${res.status})`); throw new Error(j.error || `Request failed (${res.status})`);
} }
setOk(true); setOk(true);
setSubject(''); // reset to empty strings, not null setSubject('');
setCategory('general'); setCategory('general');
setMessage(''); setMessage('');
setTimeout(() => onClose(), 1200); setTimeout(() => onClose(), 1200);
@ -46,7 +48,7 @@ export default function SupportModal({ open, onClose, userEmail }) {
} finally { } finally {
setSending(false); setSending(false);
} }
} }
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
@ -56,22 +58,12 @@ export default function SupportModal({ open, onClose, userEmail }) {
<button onClick={onClose} className="text-sm underline">Close</button> <button onClick={onClose} className="text-sm underline">Close</button>
</div> </div>
{!userEmail && ( {/* Informational note — no PII fetched/rendered */}
<p className="text-sm text-red-600 mb-2"> <p className="text-xs text-gray-600 mb-3">
You must be signed in to contact support. Well reply to the email associated with your account.
</p> </p>
)}
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="text-sm block mb-1">Well reply to</label>
<input
className="w-full border rounded px-2 py-1 bg-gray-100"
value={userEmail || ''}
readOnly
/>
</div>
<div> <div>
<label className="text-sm block mb-1">Subject (optional)</label> <label className="text-sm block mb-1">Subject (optional)</label>
<input <input
@ -116,7 +108,7 @@ export default function SupportModal({ open, onClose, userEmail }) {
<Button type="button" className="bg-gray-200 text-black" onClick={onClose}> <Button type="button" className="bg-gray-200 text-black" onClick={onClose}>
Cancel Cancel
</Button> </Button>
<Button type="submit" disabled={sending || !userEmail}> <Button type="submit" disabled={sending}>
{sending ? 'Sending…' : 'Send'} {sending ? 'Sending…' : 'Send'}
</Button> </Button>
</div> </div>

View File

@ -77,7 +77,14 @@ function UserProfile() {
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { 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; if (!res || !res.ok) return;
const data = await res.json(); const data = await res.json();

View File

@ -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(/\/+$/, ''); // Always same-origin so the session cookie goes with it
const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`; const DRAFT_URL = '/api/premium/onboarding/draft';
export async function loadDraft() { export async function loadDraft() {
const res = await authFetch(DRAFT_URL); const res = await authFetch(DRAFT_URL);
if (!res) return null; // session expired if (!res) return null; // session expired
if (res.status === 404) return null; if (res.status === 404) return null;
if (!res.ok) throw new Error(`loadDraft ${res.status}`); if (!res.ok) throw new Error(`loadDraft ${res.status}`);
return res.json(); // null or { id, step, data } return res.json(); // null or { id, step, data }
} }
/** /**
* saveDraft(input) * saveDraft(input)
* Accepts either: * Accepts either:
* - { id, step, data } // full envelope * - { id, step, data } // full envelope
* - { id, step, careerData?, financialData?, collegeData? } // partial sections * - { 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 = {}) { export async function saveDraft(input = {}) {
// Normalize inputs
let { id = null, step = 0, data } = input; let { id = null, step = 0, data } = input;
// If caller passed sections (careerData / financialData / collegeData) instead of a 'data' envelope, // Treat {} like "no data provided" so we hit the merge/guard path below.
// merge them into the existing server draft so we don't drop other sections. const isEmptyObject =
data && typeof data === 'object' && !Array.isArray(data) && Object.keys(data).length === 0;
if (isEmptyObject) data = null;
if (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; let existing = null;
try { existing = await loadDraft(); } catch (_) {} try { existing = await loadDraft(); } catch {}
const existingData = (existing && existing.data) || {}; const existingData = (existing && existing.data) || {};
const has = (k) => Object.prototype.hasOwnProperty.call(input, k); 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('financialData')) patch.financialData = input.financialData;
if (has('collegeData')) patch.collegeData = input.collegeData; 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 }; data = { ...existingData, ...patch };
// Prefer caller's id/step when provided; otherwise reuse existing
if (id == null && existing?.id != null) id = existing.id; if (id == null && existing?.id != null) id = existing.id;
if (step == null && existing?.step != null) step = existing.step; 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, { const res = await authFetch(DRAFT_URL, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@ -52,9 +76,9 @@ export async function saveDraft(input = {}) {
return res.json(); // { id, step } return res.json(); // { id, step }
} }
export async function clearDraft() { export async function clearDraft() {
const res = await authFetch(DRAFT_URL, { method: 'DELETE' }); const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
if (!res) return false; if (!res) return false;
if (!res.ok) throw new Error(`clearDraft ${res.status}`); if (!res.ok) throw new Error(`clearDraft ${res.status}`);
return true; // server returns { ok: true } return true;
} }

38
tests/smoke.sh Normal file
View File

@ -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"