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) => {
const { username, password } = req.body;
if (!username || !password) {
return res
.status(400)
.json({ error: 'Both username and password are required' });
return res.status(400).json({ error: 'Both username and password are required' });
}
// SELECT only the columns you actually have:
// 'ua.id' is user_auth's primary key,
// 'ua.user_id' references user_profile.id,
// and we alias user_profile.id as profileId for clarity.
// Only fetch what you need to verify creds
const query = `
SELECT
ua.id AS authId,
ua.user_id AS userProfileId,
ua.hashed_password,
up.firstname,
up.lastname,
up.email,
up.zipcode,
up.state,
up.area,
up.career_situation
SELECT ua.user_id AS userProfileId, ua.hashed_password
FROM user_auth ua
LEFT JOIN user_profile up ON ua.user_id = up.id
WHERE ua.username = ?
LIMIT 1
`;
try {
const [results] = await pool.query(query, [username]);
if (!results || results.length === 0) {
return res.status(401).json({ error: 'Invalid username or password' });
}
const row = results[0];
// Compare password with bcrypt
const isMatch = await bcrypt.compare(password, row.hashed_password);
const { userProfileId, hashed_password } = results[0];
const isMatch = await bcrypt.compare(password, hashed_password);
if (!isMatch) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Return user info + token
// 'authId' is user_auth's PK, but typically you won't need it on the client
// 'row.userProfileId' is the actual user_profile.id
const [profileRows] = await pool.query(
'SELECT firstname, lastname, email, zipcode, state, area, career_situation \
FROM user_profile WHERE id = ?',
[row.userProfileId]
);
const profile = profileRows[0];
if (profile?.email) {
try { profile.email = decrypt(profile.email); } catch {}
}
// Cookie-based session only; do NOT return id/token/user in body
const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
res.status(200).json({
message: 'Login successful',
token,
id: row.userProfileId,
user: profile
});
return res.status(200).json({ message: 'Login successful' });
} catch (err) {
console.error('Error querying user_auth:', err.message);
return res
.status(500)
.json({ error: 'Failed to query user authentication data' });
return res.status(500).json({ error: 'Failed to query user authentication data' });
}
});
app.post('/api/logout', (_req, res) => {
res.clearCookie(COOKIE_NAME, sessionCookieOptions());
return res.status(200).json({ ok: true });
@ -1031,29 +994,70 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
/* ------------------------------------------------------------------
FETCH USER PROFILE (MySQL)
------------------------------------------------------------------ */
FETCH USER PROFILE (MySQL) safe, minimal, no id
------------------------------------------------------------------ */
app.get('/api/user-profile', requireAuth, async (req, res) => {
const profileId = req.userId; // from requireAuth middleware
const profileId = req.userId;
try {
const [results] = await pool.query('SELECT * FROM user_profile WHERE id = ?', [profileId]);
if (!results || results.length === 0) {
return res.status(404).json({ error: 'User profile not found' });
// Optional minimal-field mode: /api/user-profile?fields=a,b,c
const raw = (req.query.fields || '').toString().trim();
if (raw) {
// No 'id' here on purpose
const ALLOW = new Set([
'username','firstname','lastname','career_situation',
'is_premium','is_pro_premium',
'state','area','zipcode',
'career_priorities','interest_inventory_answers','riasec_scores','career_list',
'email',
'phone_e164',
'sms_opt_in'
]);
const requested = raw.split(',').map(s => s.trim()).filter(Boolean);
const cols = requested.filter(c => ALLOW.has(c));
if (cols.length === 0) {
return res.status(400).json({ error: 'no_allowed_fields' });
}
const sql = `SELECT ${cols.join(', ')} FROM user_profile WHERE id = ? LIMIT 1`;
const [rows] = await pool.query(sql, [profileId]);
const row = rows && rows[0] ? rows[0] : null; // <-- declare BEFORE using
if (!row) return res.status(404).json({ error: 'User profile not found' });
// Decrypt only if explicitly requested and present
if (cols.includes('email') && row.email) {
try { row.email = decrypt(row.email); } catch {}
}
// Phone may be encrypted; normalize sms_opt_in to boolean
if (cols.includes('phone_e164') && typeof row.phone_e164 === 'string' && row.phone_e164.startsWith('gcm:')) {
try { row.phone_e164 = decrypt(row.phone_e164); } catch {}
}
if (cols.includes('sms_opt_in')) {
row.sms_opt_in = !!row.sms_opt_in; // BIT/TINYINT → boolean
}
return res.status(200).json(row);
}
const row = results[0];
if (row?.email) {
try { row.email = decrypt(row.email); } catch {}
}
res.status(200).json(row);
// Legacy fallback: return only something harmless (no id/email)
const [rows] = await pool.query(
'SELECT firstname FROM user_profile WHERE id = ? LIMIT 1',
[profileId]
);
const row = rows && rows[0] ? rows[0] : null;
if (!row) return res.status(404).json({ error: 'User profile not found' });
return res.status(200).json(row);
} catch (err) {
console.error('Error fetching user profile:', err.message);
res.status(500).json({ error: 'Internal server error' });
console.error('Error fetching user profile:', err?.message || err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------
SALARY_INFO REMAINS IN SQLITE
------------------------------------------------------------------ */

View File

@ -60,9 +60,6 @@ const chatLimiter = rateLimit({
keyGenerator: req => req.user?.id || req.ip
});
// ── RUNTIME PROTECTION: outbound host allowlist (server2) ──
const OUTBOUND_ALLOW = new Set([
'services.onetcenter.org', // O*NET
@ -199,6 +196,7 @@ const EXEMPT_PATHS = [
app.use((req, res, next) => {
if (!req.path.startsWith('/api/')) return next();
if (isHotReloadPath(req)) return next();
if (!MUST_JSON.has(req.method)) return next();
if (EXEMPT_PATHS.some(rx => rx.test(req.path))) return next();
@ -330,18 +328,21 @@ process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e)
process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
// ---- RUNTIME PROTECTION: HPP guard (dedupe + cap arrays) ----
app.use((req, _res, next) => {
const MAX_ARRAY = 20; // sane cap; adjust if you truly need more
// Bypass guard on hot reload routes to avoid slicing/false negatives
if (isHotReloadPath(req)) return next();
const MAX_ARRAY = 20; // keep stricter cap elsewhere
const sanitize = (obj) => {
if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) {
const v = obj[k];
if (Array.isArray(v)) {
// keep first value semantics + bound array size
obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null);
if (obj[k].length === 1) obj[k] = obj[k][0]; // collapse singletons
if (obj[k].length === 1) obj[k] = obj[k][0];
}
}
};
@ -353,6 +354,7 @@ app.use((req, _res, next) => {
// ---- RUNTIME: reject request bodies on GET/HEAD ----
app.use((req, res, next) => {
if (isHotReloadPath(req)) return next();
if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) {
return res.status(400).json({ error: 'no_body_allowed' });
}
@ -379,6 +381,16 @@ app.get('/readyz', async (_req, res) => {
}
});
const isHotReloadPath = (req) => {
if (!req || !req.path) return false;
if (req.path.startsWith('/api/onet/')) return true;
if (req.path.startsWith('/api/cip/')) return true;
if (req.path.startsWith('/api/projections/')) return true;
if (req.path.startsWith('/api/salary')) return true;
if (req.method === 'POST' && req.path === '/api/job-zones') return true;
return false;
};
// 3) Health: detailed JSON you can curl
app.get('/healthz', async (_req, res) => {
const out = {
@ -483,6 +495,10 @@ const supportDailyLimiter = rateLimit({
* DB connections (SQLite)
**************************************************/
let dbSqlite;
// Salary fast path: prepared stmt + tiny LRU cache
let SALARY_STMT = null;
const SALARY_CACHE = new Map(); // key: `${occ}|${area}` -> result
const SALARY_CACHE_MAX = 512;
let userProfileDb;
async function initDatabases() {
@ -493,6 +509,30 @@ async function initDatabases() {
mode : sqlite3.OPEN_READONLY
});
console.log('✅ Connected to salary_info.db');
// Light PRAGMAs safe for read-only workload
await dbSqlite.exec(`
PRAGMA busy_timeout=4000;
PRAGMA journal_mode=OFF;
PRAGMA synchronous=OFF;
PRAGMA temp_store=MEMORY;
`);
// One prepared statement: regional (param) + national in a single scan
SALARY_STMT = await dbSqlite.prepare(`
SELECT
MAX(CASE WHEN AREA_TITLE = ? THEN A_PCT10 END) AS regional_PCT10,
MAX(CASE WHEN AREA_TITLE = ? THEN A_PCT25 END) AS regional_PCT25,
MAX(CASE WHEN AREA_TITLE = ? THEN A_MEDIAN END) AS regional_MEDIAN,
MAX(CASE WHEN AREA_TITLE = ? THEN A_PCT75 END) AS regional_PCT75,
MAX(CASE WHEN AREA_TITLE = ? THEN A_PCT90 END) AS regional_PCT90,
MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_PCT10 END) AS national_PCT10,
MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_PCT25 END) AS national_PCT25,
MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_MEDIAN END) AS national_MEDIAN,
MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_PCT75 END) AS national_PCT75,
MAX(CASE WHEN AREA_TITLE = 'U.S.' THEN A_PCT90 END) AS national_PCT90
FROM salary_data
WHERE OCC_CODE = ?
AND (AREA_TITLE = ? OR AREA_TITLE = 'U.S.')
`);
userProfileDb = await open({
filename: USER_PROFILE_DB_PATH,
@ -563,16 +603,72 @@ app.use((req, res, next) => next());
// ──────────────────────────────── Data endpoints ───────────────────────────────
// /api/data/careers-with-ratings → backend/data/careers_with_ratings.json
app.get('/api/data/careers-with-ratings', (req, res) => {
const p = path.join(DATA_DIR, 'careers_with_ratings.json');
fs.access(p, fs.constants.R_OK, (err) => {
if (err) return res.status(404).json({ error: 'careers_with_ratings.json not found' });
res.type('application/json');
res.sendFile(p);
});
/**************************************************
* BULK limited-data computation (single call)
* - Input: { socCodes: [full SOCs], state, area }
* - Output: { [fullSoc]: { job_zone, limitedData } }
**************************************************/
app.post('/api/suggestions/limited-data', async (req, res) => {
try {
const socCodes = Array.isArray(req.body?.socCodes) ? req.body.socCodes : [];
const stateIn = String(req.body?.state || '');
const area = String(req.body?.area || '');
if (!socCodes.length) return res.json({});
const fullState = fullStateFrom(stateIn);
// Pre-dedupe base SOCs for base-scoped work (projections, salary, job_zone)
const bases = [...new Set(socCodes.map(baseSocOf))];
// Precompute base-scoped booleans/zones
const projMap = new Map();
const salMap = new Map();
const zoneMap = new Map();
await Promise.all(bases.map(async (b) => {
const [hasProj, hasSal, zone] = await Promise.all([
Promise.resolve(projectionsExist(b, fullState)),
salaryExist(b, area),
getJobZone(b)
]);
projMap.set(b, !!hasProj);
salMap.set(b, !!hasSal);
zoneMap.set(b, zone ?? null);
}));
// Build per-full-SOC answers (CIP + O*NET desc presence)
const out = {};
for (const fullSoc of socCodes) {
const base = baseSocOf(fullSoc);
const hasCip = hasCipForFullSoc(fullSoc);
// O*NET presence (from our tiny cache table). If unknown → treat as false for now
// (conservative; first modal open will populate and future runs will be correct).
let hasDesc = false;
let hasTasks = false;
try {
const p = await onetDescPresence(fullSoc);
if (p) { hasDesc = !!p.has_desc; hasTasks = !!p.has_tasks; }
} catch {}
const hasProj = !!projMap.get(base);
const hasSal = !!salMap.get(base);
const job_zone = zoneMap.get(base) ?? null;
const limitedData = !(hasCip && (hasDesc || hasTasks) && hasProj && hasSal);
out[fullSoc] = { job_zone, limitedData };
}
return res.json(out);
} catch (e) {
console.error('[limited-data bulk]', e?.message || e);
return res.status(500).json({ error: 'failed' });
}
});
// /api/data/cip-institution-map → backend/data/cip_institution_mapping_new.json (or fallback)
app.get('/api/data/cip-institution-map', (req, res) => {
const candidates = [
@ -621,6 +717,13 @@ const socToCipMapping = loadMapping();
if (socToCipMapping.length === 0) {
console.error('SOC to CIP mapping data is empty.');
}
// O(1) CIP index: FULL SOC -> CIP code (first match wins)
const CIP_BY_SOC = new Map();
for (const row of socToCipMapping) {
const soc = String(row['O*NET-SOC 2019 Code'] || '').trim();
const cip = row['2020 CIP Code'];
if (soc && cip && !CIP_BY_SOC.has(soc)) CIP_BY_SOC.set(soc, cip);
}
/**************************************************
* Load single JSON with all states + US
@ -636,6 +739,25 @@ try {
console.error('Error reading economicproj.json:', err);
}
// O(1) projections index: key = `${occ}|${areaLower}`
const PROJ_BY_KEY = new Map();
for (const r of allProjections) {
const occ = String(r['Occupation Code'] || '').trim();
const area = String(r['Area Name'] || '').trim().toLowerCase();
if (!occ || !area) continue;
PROJ_BY_KEY.set(`${occ}|${area}`, {
area : r['Area Name'],
baseYear : r['Base Year'],
base : r['Base'],
projectedYear : r['Projected Year'],
projection : r['Projection'],
change : r['Change'],
percentChange : r['Percent Change'],
annualOpenings: r['Average Annual Openings'],
occupationName: r['Occupation Name'],
});
}
//AI At Risk helpers
async function getRiskAnalysisFromDB(socCode) {
const row = await userProfileDb.get(
@ -645,6 +767,96 @@ async function getRiskAnalysisFromDB(socCode) {
return row || null;
}
/*
* Helpers used by /api/onet/submit_answers for limited_data
* - Uses ONLY local sources you already load:
* socToCipMapping (Excel in-memory array)
* allProjections (economicproj.json in-memory array)
* dbSqlite (salary_info.db SQLite handle)
* - No external HTTP calls. No logging of SOCs.
* */
const baseSocOf = (fullSoc = '') => String(fullSoc).split('.')[0]; // "15-1252.01" → "15-1252"
const fullStateFrom = (s = '') => {
const M = {
AL:'Alabama', AK:'Alaska', AZ:'Arizona', AR:'Arkansas', CA:'California', CO:'Colorado',
CT:'Connecticut', DE:'Delaware', DC:'District of Columbia', FL:'Florida', GA:'Georgia',
HI:'Hawaii', ID:'Idaho', IL:'Illinois', IN:'Indiana', IA:'Iowa', KS:'Kansas',
KY:'Kentucky', LA:'Louisiana', ME:'Maine', MD:'Maryland', MA:'Massachusetts',
MI:'Michigan', MN:'Minnesota', MS:'Mississippi', MO:'Missouri', MT:'Montana',
NE:'Nebraska', NV:'Nevada', NH:'New Hampshire', NJ:'New Jersey', NM:'New Mexico',
NY:'New York', NC:'North Carolina', ND:'North Dakota', OH:'Ohio', OK:'Oklahoma',
OR:'Oregon', PA:'Pennsylvania', RI:'Rhode Island', SC:'South Carolina',
SD:'South Dakota', TN:'Tennessee', TX:'Texas', UT:'Utah', VT:'Vermont',
VA:'Virginia', WA:'Washington', WV:'West Virginia', WI:'Wisconsin', WY:'Wyoming'
};
if (!s) return '';
const up = String(s).trim().toUpperCase();
return M[up] || s; // already full name → return as-is
};
/** CIP presence from your loaded Excel mapping (exact FULL SOC match) */
const hasCipForFullSoc = (fullSoc = '') => {
const want = String(fullSoc).trim();
for (const row of socToCipMapping) {
if (String(row['O*NET-SOC 2019 Code'] || '').trim() === want) return true;
}
return false;
};
/** Projections presence from economicproj.json
* True if a row exists for base SOC in the requested state (full name) OR in "United States".
*/
function projectionsExist(baseSoc, fullState) {
const code = String(baseSoc).trim();
const wantState = (fullState || '').trim().toLowerCase();
const hasState = wantState
? allProjections.some(r =>
String(r['Occupation Code']).trim() === code &&
String(r['Area Name'] || '').trim().toLowerCase() === wantState
)
: false;
const hasUS = allProjections.some(r =>
String(r['Occupation Code']).trim() === code &&
String(r['Area Name'] || '').trim().toLowerCase() === 'united states'
);
return hasState || hasUS;
}
/** Salary presence from salary_info.db
* True if regional row exists (base SOC + AREA_TITLE == area); otherwise fallback to U.S.
*/
async function salaryExist(baseSoc, area) {
const occ = String(baseSoc).trim();
const a = String(area || '').trim();
if (a) {
const regional = await dbSqlite.get(
`SELECT 1 FROM salary_data WHERE OCC_CODE = ? AND AREA_TITLE = ? LIMIT 1`,
[occ, a]
);
if (regional) return true;
}
const national = await dbSqlite.get(
`SELECT 1 FROM salary_data WHERE OCC_CODE = ? AND AREA_TITLE = 'U.S.' LIMIT 1`,
[occ]
);
return !!national;
}
/** Compute limited_data exactly once for a given career row */
async function computeLimitedFor(fullSoc, stateAbbrevOrName, areaTitle) {
const base = baseSocOf(fullSoc);
const fullSt = fullStateFrom(stateAbbrevOrName) || 'United States';
const hasCip = hasCipForFullSoc(fullSoc);
const hasProj = projectionsExist(base, fullSt);
const hasSal = await salaryExist(base, areaTitle);
return !(hasCip && hasProj && hasSal);
}
// Helper to upsert a row
async function storeRiskAnalysisInDB({
socCode,
@ -970,18 +1182,11 @@ app.get('/api/onet/career-description/:socCode', async (req, res) => {
// CIP route
app.get('/api/cip/:socCode', (req, res) => {
const { socCode } = req.params;
console.log(`Received SOC Code: ${socCode.trim()}`);
for (let row of socToCipMapping) {
const mappedSOC = row['O*NET-SOC 2019 Code']?.trim();
if (mappedSOC === socCode.trim()) {
console.log('Found CIP code:', row['2020 CIP Code']);
return res.json({ cipCode: row['2020 CIP Code'] });
}
}
console.error('SOC code not found in mapping:', socCode);
res.status(404).json({ error: 'CIP code not found' });
});
const key = String(socCode || '').trim();
const cip = CIP_BY_SOC.get(key);
if (cip) return res.json({ cipCode: cip });
return res.status(404).json({ error: 'CIP code not found' });
});
/** @aiTool {
"name": "getSchoolsForCIPs",
@ -1142,60 +1347,19 @@ app.get('/api/tuition', (req, res) => {
/**************************************************
* SINGLE route for projections from economicproj.json
**************************************************/
app.get('/api/projections/:socCode', (req, res) => {
const { socCode } = req.params;
const { state } = req.query;
console.log('Projections request for', socCode, ' state=', state);
if (!socCode) {
return res.status(400).json({ error: 'SOC Code is required.' });
app.get('/api/projections/:socCode', (req, res) => {
const { socCode } = req.params;
const { state } = req.query;
const occ = String(socCode).trim();
const areaKey = String(state ? state : 'United States').trim().toLowerCase();
const rowState = PROJ_BY_KEY.get(`${occ}|${areaKey}`) || null;
const rowUS = PROJ_BY_KEY.get(`${occ}|united states`) || null;
if (!rowState && !rowUS) {
return res.status(404).json({ error: 'No projections found for this SOC + area.' });
}
return res.json({ state: rowState, national: rowUS });
});
// If no ?state=, default to "United States"
const areaName = state ? state.trim() : 'United States';
// Find the row for the requested area
const rowForState = allProjections.find(
(row) =>
row['Occupation Code'] === socCode.trim() &&
row['Area Name']?.toLowerCase() === areaName.toLowerCase()
);
// Also find the row for "United States"
const rowForUS = allProjections.find(
(row) =>
row['Occupation Code'] === socCode.trim() &&
row['Area Name']?.toLowerCase() === 'united states'
);
if (!rowForState && !rowForUS) {
return res
.status(404)
.json({ error: 'No projections found for this SOC + area.' });
}
function formatRow(r) {
if (!r) return null;
return {
area: r['Area Name'],
baseYear: r['Base Year'],
base: r['Base'],
projectedYear: r['Projected Year'],
projection: r['Projection'],
change: r['Change'],
percentChange: r['Percent Change'],
annualOpenings: r['Average Annual Openings'],
occupationName: r['Occupation Name'],
};
}
const result = {
state: formatRow(rowForState),
national: formatRow(rowForUS),
};
return res.json(result);
});
/** @aiTool {
"name": "getSalaryData",
@ -1217,49 +1381,51 @@ app.get('/api/projections/:socCode', (req, res) => {
**************************************************/
app.get('/api/salary', async (req, res) => {
const { socCode, area } = req.query;
console.log('Received /api/salary request:', { socCode, area });
if (!socCode) {
return res.status(400).json({ error: 'SOC Code is required' });
}
const regionalQuery = `
SELECT A_PCT10 AS regional_PCT10,
A_PCT25 AS regional_PCT25,
A_MEDIAN AS regional_MEDIAN,
A_PCT75 AS regional_PCT75,
A_PCT90 AS regional_PCT90
FROM salary_data
WHERE OCC_CODE = ? AND AREA_TITLE = ?
`;
const nationalQuery = `
SELECT A_PCT10 AS national_PCT10,
A_PCT25 AS national_PCT25,
A_MEDIAN AS national_MEDIAN,
A_PCT75 AS national_PCT75,
A_PCT90 AS national_PCT90
FROM salary_data
WHERE OCC_CODE = ? AND AREA_TITLE = 'U.S.'
`;
try {
let regionalRow = null;
let nationalRow = null;
const keyArea = String(area || ''); // allow empty → national only
const cacheKey = `${socCode}|${keyArea}`;
const cached = SALARY_CACHE.get(cacheKey);
if (cached) return res.json(cached);
if (area) {
regionalRow = await dbSqlite.get(regionalQuery, [socCode, area]);
}
nationalRow = await dbSqlite.get(nationalQuery, [socCode]);
// Bind regional placeholders (five times) + occ + area
const row = await SALARY_STMT.get(
keyArea, keyArea, keyArea, keyArea, keyArea, socCode, keyArea
);
if (!regionalRow && !nationalRow) {
console.log('No salary data found for:', { socCode, area });
const regional = {
regional_PCT10 : row?.regional_PCT10 ?? undefined,
regional_PCT25 : row?.regional_PCT25 ?? undefined,
regional_MEDIAN : row?.regional_MEDIAN ?? undefined,
regional_PCT75 : row?.regional_PCT75 ?? undefined,
regional_PCT90 : row?.regional_PCT90 ?? undefined,
};
const national = {
national_PCT10 : row?.national_PCT10 ?? undefined,
national_PCT25 : row?.national_PCT25 ?? undefined,
national_MEDIAN : row?.national_MEDIAN ?? undefined,
national_PCT75 : row?.national_PCT75 ?? undefined,
national_PCT90 : row?.national_PCT90 ?? undefined,
};
// If both are empty, 404 to match old behavior
if (
Object.values(regional).every(v => v == null) &&
Object.values(national).every(v => v == null)
) {
return res.status(404).json({ error: 'No salary data found' });
}
const salaryData = {
regional: regionalRow || {},
national: nationalRow || {},
};
console.log('Salary data retrieved:', salaryData);
res.json(salaryData);
const payload = { regional, national };
// Tiny LRU: cap at 512 entries
SALARY_CACHE.set(cacheKey, payload);
if (SALARY_CACHE.size > SALARY_CACHE_MAX) {
const first = SALARY_CACHE.keys().next().value;
SALARY_CACHE.delete(first);
}
res.json(payload);
} catch (error) {
console.error('Error executing salary query:', error.message);
res.status(500).json({ error: 'Failed to fetch salary data' });
@ -1376,27 +1542,6 @@ app.get('/api/skills/:socCode', async (req, res) => {
}
});
/**************************************************
* user-profile by ID route
**************************************************/
app.get('/api/user-profile/:id', (req, res) => {
const { id } = req.params;
if (!id) return res.status(400).json({ error: 'Profile ID is required' });
const query = `SELECT area, zipcode FROM user_profile WHERE id = ?`;
userProfileDb.get(query, [id], (err, row) => {
if (err) {
console.error('Error fetching user profile:', err.message);
return res.status(500).json({ error: 'Failed to fetch user profile' });
}
if (!row) {
return res.status(404).json({ error: 'Profile not found' });
}
res.json({ area: row.area, zipcode: row.zipcode });
});
});
/***************************************************
* AI RISK ASSESSMENT ENDPOINT READ
****************************************************/
@ -1498,9 +1643,25 @@ app.post(
accountEmail = row?.email || null;
} catch {}
}
// If still missing, fetch from server1 using the caller's session
if (!accountEmail) {
accountEmail = (req.body && req.body.email) || null;
try {
const r = await fetch('http://server1:5000/api/user-profile?fields=email', {
method: 'GET',
headers: {
// forward caller's auth — either cookie (HttpOnly session) or bearer
'Authorization': req.headers.authorization || '',
'Cookie': req.headers.cookie || ''
}
});
if (r.ok && (r.headers.get('content-type') || '').includes('application/json')) {
const j = await r.json();
accountEmail = j?.email || null;
}
} catch { /* best-effort; fall through to error below if still null */ }
}
if (!accountEmail) {
return res.status(400).json({ error: 'No email on file for this user' });
}

View File

@ -47,11 +47,7 @@ const API_BASE = `${INTERNAL_SELF_BASE}/api`;
const DATA_DIR = path.join(__dirname, 'data');
/* ─── helper: canonical public origin ─────────────────────────── */
const PUBLIC_BASE = (
process.env.APTIVA_AI_BASE
|| process.env.REACT_APP_API_URL
|| ''
).replace(/\/+$/, '');
const PUBLIC_BASE = (process.env.APTIVA_API_BASE || '').replace(/\/+$/, '');
const ALLOWED_REDIRECT_HOSTS = new Set([
new URL(PUBLIC_BASE || 'http://localhost').host
@ -60,7 +56,7 @@ const ALLOWED_REDIRECT_HOSTS = new Set([
// ── RUNTIME PROTECTION: outbound host allowlist (server3) ──
const OUTBOUND_ALLOW = new Set([
'server2', // compose DNS (server2:5001)
'server3', // self-calls (localhost:5002)
'server3', // self-calls (server3:5002)
'api.openai.com', // OpenAI SDK traffic
'api.stripe.com', // Stripe SDK traffic
'api.twilio.com' // smsService may hit Twilio from this proc
@ -93,9 +89,10 @@ const app = express();
app.use(cookieParser());
app.disable('x-powered-by');
app.set('trust proxy', 1);
app.use(express.json({ limit: '1mb' }));
app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
// --- Request ID + minimal audit log for /api/* ---
function getRequestId(req, res) {
const hdr = req.headers['x-request-id'];
@ -507,6 +504,34 @@ async function getRiskAnalysisFromDB(socCode) {
return rows.length > 0 ? rows[0] : null;
}
function safeMilestoneRow(m) {
return {
id: m.id,
career_profile_id: m.career_profile_id,
title: m.title,
description: m.description,
date: m.date,
progress: m.progress,
status: m.status,
is_universal: m.is_universal ? 1 : 0,
origin_milestone_id: m.origin_milestone_id || null,
created_at: m.created_at,
updated_at: m.updated_at
};
}
function safeTaskRow(t) {
return {
id: t.id,
milestone_id: t.milestone_id,
title: t.title,
description: t.description,
due_date: t.due_date,
status: t.status,
created_at: t.created_at,
updated_at: t.updated_at
};
}
async function storeRiskAnalysisInDB({
socCode,
careerName,
@ -558,25 +583,134 @@ const COOKIE_NAME = process.env.COOKIE_NAME || 'aptiva_session';
//*PremiumOnboarding draft
// GET current user's draft
app.get('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => {
const [[row]] = await pool.query(
'SELECT id, step, data FROM onboarding_drafts WHERE user_id=?',
const [[row]] = await pool.query(
`SELECT id, step, data
FROM onboarding_drafts
WHERE user_id=?
ORDER BY updated_at DESC, id DESC
LIMIT 1`,
[req.id]
);
return res.json(row || null);
});
// POST upsert draft
// POST upsert draft (ID-agnostic, partial merge, 1 draft per user)
app.post('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => {
const { id, step = 0, data = {} } = req.body || {};
const draftId = id || uuidv4();
await pool.query(`
INSERT INTO onboarding_drafts (user_id,id,step,data)
VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE step=VALUES(step), data=VALUES(data)
`, [req.id, draftId, step, JSON.stringify(data)]);
res.json({ id: draftId, step });
try {
// ---- 0) Harden req.body and incoming shapes
const body = (req && typeof req.body === 'object' && req.body) ? req.body : {};
let { id, step } = body;
// Accept either {data:{careerData/financialData/collegeData}} or section keys at top level
let incoming = {};
if (body.data != null) {
if (typeof body.data === 'string') {
try { incoming = JSON.parse(body.data); } catch { incoming = {}; }
} else if (typeof body.data === 'object') {
incoming = body.data;
}
}
// If callers provided sections directly (EducationalProgramsPage),
// lift them into the data envelope without crashing if body is blank.
['careerData','financialData','collegeData'].forEach(k => {
if (Object.prototype.hasOwnProperty.call(body, k)) {
if (!incoming || typeof incoming !== 'object') incoming = {};
incoming[k] = body[k];
}
});
// ---- 1) Base draft: by id (if provided) else latest for this user
let base = null;
if (id) {
const [[row]] = await pool.query(
`SELECT id, step, data FROM onboarding_drafts WHERE user_id=? AND id=? LIMIT 1`,
[req.id, id]
);
base = row || null;
} else {
const [[row]] = await pool.query(
`SELECT id, step, data
FROM onboarding_drafts
WHERE user_id=?
ORDER BY updated_at DESC, id DESC
LIMIT 1`,
[req.id]
);
base = row || null;
}
// ---- 2) Parse prior JSON safely
let prev = {};
if (base?.data != null) {
try {
if (typeof base.data === 'string') prev = JSON.parse(base.data);
else if (Buffer.isBuffer(base.data)) prev = JSON.parse(base.data.toString('utf8'));
else if (typeof base.data === 'object') prev = base.data;
} catch { prev = {}; }
}
// ---- 3) Section-wise shallow merge (prev + incoming)
const merged = mergeDraft(prev, (incoming && typeof incoming === 'object') ? incoming : {});
// ---- 3.5) Refuse empty drafts so we don't wipe real data
const isEmptyObject = (o) => o && typeof o === 'object' && !Array.isArray(o) && Object.keys(o).length === 0;
const allSubsectionsEmpty =
isEmptyObject(merged) ||
(
typeof merged === 'object' &&
['careerData','financialData','collegeData'].every(k => !merged[k] || isEmptyObject(merged[k]))
);
if (allSubsectionsEmpty) {
return res.status(400).json({ error: 'empty_draft' });
}
console.log('[draft-upsert]', {
userId : req.id,
draftId : draftId,
step : finalStep,
incoming : Object.keys(incoming || {}).sort(),
mergedKeys: Object.keys(merged || {}).sort(),
});
// ---- 4) Final id/step and upsert
const draftId = base?.id || id || uuidv4();
const finalStep = Number.isInteger(step) ? step : (parseInt(step,10) || base?.step || 0);
await pool.query(
`INSERT INTO onboarding_drafts (user_id, id, step, data)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
step = VALUES(step),
data = VALUES(data),
updated_at = CURRENT_TIMESTAMP`,
[req.id, draftId, finalStep, JSON.stringify(merged)]
);
return res.json({ id: draftId, step: finalStep });
} catch (e) {
console.error('draft upsert failed:', e?.message || e);
return res.status(500).json({ error: 'draft_upsert_failed' });
}
});
// unchanged
function mergeDraft(a = {}, b = {}) {
const out = { ...a };
for (const k of Object.keys(b || {})) {
const left = a[k];
const right = b[k];
if (
left && typeof left === 'object' && !Array.isArray(left) &&
right && typeof right === 'object' && !Array.isArray(right)
) {
out[k] = { ...left, ...right };
} else {
out[k] = right;
}
}
return out;
}
// DELETE draft (after finishing / cancelling)
app.delete('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => {
await pool.query('DELETE FROM onboarding_drafts WHERE user_id=?', [req.id]);
@ -598,32 +732,73 @@ app.post(
console.error('⚠️ Bad Stripe signature', err.message);
return res.status(400).end();
}
// Env guard: only handle events matching our env
const isProd = (process.env.NODE_ENV === 'prod');
if (Boolean(event.livemode) !== isProd) {
console.warn('[Stripe] Ignoring webhook due to livemode mismatch', { livemode: event.livemode, isProd });
return res.sendStatus(200);
}
const upFlags = async (customerId, premium, pro) => {
const upFlags = async (customerId, premium, pro) => {
const h = hashForLookup(customerId);
console.log('[Stripe] upFlags', { customerId, premium, pro });
await pool.query(
`UPDATE user_profile
SET is_premium = ?, is_pro_premium = ?
WHERE stripe_customer_id_hash = ?`,
[premium, pro, h]
SET is_premium = ?, is_pro_premium = ?
WHERE stripe_customer_id_hash = ?`,
[premium ? 1 : 0, pro ? 1 : 0, h]
);
};
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const sub = event.data.object;
const pid = sub.items.data[0].price.id;
const tier = [process.env.STRIPE_PRICE_PRO_MONTH,
process.env.STRIPE_PRICE_PRO_YEAR].includes(pid)
? 'pro' : 'premium';
await upFlags(sub.customer, tier === 'premium', tier === 'pro');
break;
// Recompute flags from Stripe (source of truth)
const recomputeFlagsFromStripe = async (customerId) => {
const subs = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100
});
// Consider only “active-like” states
const ACTIVE_STATES = new Set(['active']);
let hasPremium = false;
let hasPro = false;
// after computing hasPremium/hasPro
const activeCount = subs.data.filter(s => ACTIVE_STATES.has(s.status)).length;
if (activeCount > 1) {
console.warn('[Stripe] multiple active subs for customer', { customerId, activeCount });
}
for (const s of subs.data) {
if (!ACTIVE_STATES.has(s.status)) continue;
for (const item of s.items.data) {
const pid = item.price.id;
if (pid === process.env.STRIPE_PRICE_PRO_MONTH || pid === process.env.STRIPE_PRICE_PRO_YEAR) {
hasPro = true;
}
if (pid === process.env.STRIPE_PRICE_PREMIUM_MONTH || pid === process.env.STRIPE_PRICE_PREMIUM_YEAR) {
hasPremium = true;
}
}
}
// If any Pro sub exists, Pro wins; otherwise Premium if any premium exists
// Pro implies premium access; premium only if no pro
const finalIsPro = hasPro ? 1 : 0;
const finalIsPremium = hasPro ? 1 : (hasPremium ? 1 : 0);
await upFlags(customerId, finalIsPremium, finalIsPro);
};
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted': {
const sub = event.data.object;
await upFlags(sub.customer, 0, 0);
await recomputeFlagsFromStripe(sub.customer);
break;
}
case 'checkout.session.completed': {
// extra safety for some Stripe flows that rely on this event
const ses = event.data.object;
if (ses.customer) {
await recomputeFlagsFromStripe(ses.customer);
}
break;
}
default:
@ -634,8 +809,11 @@ app.post(
);
// 2) Basic middlewares
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
//leave below Stripe webhook
app.use(express.json({ limit: '5mb' }));
/* ─── Require critical env vars ─────────────────────────────── */
@ -684,10 +862,6 @@ function authenticatePremiumUser(req, res, next) {
}
};
/** ------------------------------------------------------------------
* Returns the 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.
@ -726,6 +900,21 @@ async function getOrCreateStripeCustomerId(req) {
return customer.id;
}
// ── Stripe: detect if customer already has an active (or pending) sub ─────────
async function customerHasActiveSub(customerId) {
// keep it small; we only need to know if ≥1 exists
const list = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 5
});
return list.data.some(s => {
// treat cancel_at_period_end as still-active for gating
if (s.cancel_at_period_end) return true;
return ['active'].includes(s.status);
});
}
/* ------------------------------------------------------------------ */
const priceMap = {
@ -856,17 +1045,16 @@ async function ensureDescriptionAndTasks({ socCode, jobDescription, tasks }) {
// GET the latest selected career profile
app.get('/api/premium/career-profile/latest', authenticatePremiumUser, async (req, res) => {
try {
const sql = `
SELECT
*,
start_date AS start_date
FROM career_profiles
WHERE user_id = ?
ORDER BY start_date DESC
LIMIT 1
`;
const [rows] = await pool.query(sql, [req.id]);
res.json(rows[0] || {});
const [rows] = await pool.query(
`SELECT * FROM career_profiles
WHERE user_id = ?
ORDER BY start_date DESC
LIMIT 1`,
[req.id]
);
const row = rows[0] ? { ...rows[0] } : null;
if (row) delete row.user_id;
return res.json(row || {});
} catch (error) {
console.error('Error fetching latest career profile:', error);
res.status(500).json({ error: 'Failed to fetch latest career profile' });
@ -878,8 +1066,12 @@ app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req,
try {
const sql = `
SELECT
*,
start_date AS start_date
id,
scenario_title,
career_name,
status,
DATE_FORMAT(start_date, '%Y-%m-%d') AS start_date,
DATE_FORMAT(created_at, '%Y-%m-%d %H:%i:%s') AS created_at
FROM career_profiles
WHERE user_id = ?
ORDER BY start_date ASC
@ -910,7 +1102,9 @@ app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser,
if (!rows[0]) {
return res.status(404).json({ error: 'Career profile not found or not yours.' });
}
res.json(rows[0]);
const row = { ...rows[0] };
delete row.user_id; // do not ship user_id
return res.json(row);
} catch (error) {
console.error('Error fetching single career profile:', error);
res.status(500).json({ error: 'Failed to fetch career profile by ID.' });
@ -2601,33 +2795,32 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
const id = uuidv4();
await pool.query(`
INSERT INTO milestones (
id,
user_id,
career_profile_id,
title,
description,
date,
progress,
status,
is_universal
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
INSERT INTO milestones (
id,
req.id,
user_id,
career_profile_id,
title,
description || '',
description,
date,
progress || 0,
status || 'planned',
is_universal ? 1 : 0
]);
progress,
status,
is_universal
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
req.id,
career_profile_id,
title,
description || '',
date,
progress || 0,
status || 'planned',
is_universal ? 1 : 0
]);
createdMilestones.push({
id,
user_id: req.id,
career_profile_id,
title,
description: description || '',
@ -2697,7 +2890,17 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
is_universal: is_universal ? 1 : 0,
tasks: []
};
return res.status(201).json(newMilestone);
return res.status(201).json({
id,
career_profile_id,
title,
description: description || '',
date,
progress: progress || 0,
status: status || 'planned',
is_universal: is_universal ? 1 : 0,
tasks: []
});
} catch (err) {
console.error('Error creating milestone(s):', err);
res.status(500).json({ error: 'Failed to create milestone(s).' });
@ -2777,9 +2980,9 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
`, [milestoneId]);
res.json({
...updatedMilestoneRow,
tasks: tasks || []
});
...safeMilestoneRow(updatedMilestoneRow),
tasks: (tasks || []).map(safeTaskRow)
});
} catch (err) {
console.error('Error updating milestone:', err);
res.status(500).json({ error: 'Failed to update milestone.' });
@ -2811,13 +3014,13 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
tasksByMilestone = taskRows.reduce((acc, t) => {
if (!acc[t.milestone_id]) acc[t.milestone_id] = [];
acc[t.milestone_id].push(t);
acc[t.milestone_id].push(safeTaskRow(t));
return acc;
}, {});
}
const uniMils = universalRows.map(m => ({
...m,
const uniMils = universalRows.map(m => ({
...safeMilestoneRow(m),
tasks: tasksByMilestone[m.id] || []
}));
return res.json({ milestones: uniMils });
@ -2843,13 +3046,13 @@ app.get('/api/premium/milestones', authenticatePremiumUser, async (req, res) =>
tasksByMilestone = taskRows.reduce((acc, t) => {
if (!acc[t.milestone_id]) acc[t.milestone_id] = [];
acc[t.milestone_id].push(t);
acc[t.milestone_id].push(safeTaskRow(t));
return acc;
}, {});
}
const milestonesWithTasks = milestones.map(m => ({
...m,
...safeMilestoneRow(m),
tasks: tasksByMilestone[m.id] || []
}));
res.json({ milestones: milestonesWithTasks });
@ -3113,11 +3316,24 @@ app.delete('/api/premium/milestones/:milestoneId', authenticatePremiumUser, asyn
app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, res) => {
try {
const [rows] = await pool.query(
'SELECT * FROM financial_profiles WHERE user_id=? LIMIT 1',
`SELECT
current_salary,
additional_income,
monthly_expenses,
monthly_debt_payments,
retirement_savings,
emergency_fund,
retirement_contribution,
emergency_contribution,
extra_cash_emergency_pct,
extra_cash_retirement_pct
FROM financial_profiles
WHERE user_id=? LIMIT 1`,
[req.id]
);
if (!rows.length) {
// minimal, id-free default payload
return res.json({
current_salary: 0,
additional_income: 0,
@ -3131,7 +3347,20 @@ app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, r
extra_cash_retirement_pct: 50
});
}
res.json(rows[0]);
const r = rows[0] || {};
// ensure consistent numeric types; no ids/user_id/timestamps returned
return res.json({
current_salary: Number(r.current_salary ?? 0),
additional_income: Number(r.additional_income ?? 0),
monthly_expenses: Number(r.monthly_expenses ?? 0),
monthly_debt_payments: Number(r.monthly_debt_payments ?? 0),
retirement_savings: Number(r.retirement_savings ?? 0),
emergency_fund: Number(r.emergency_fund ?? 0),
retirement_contribution: Number(r.retirement_contribution ?? 0),
emergency_contribution: Number(r.emergency_contribution ?? 0),
extra_cash_emergency_pct: Number(r.extra_cash_emergency_pct ?? 50),
extra_cash_retirement_pct: Number(r.extra_cash_retirement_pct ?? 50)
});
} catch (err) {
console.error('financialprofile GET error:', err);
res.status(500).json({ error: 'DB error' });
@ -3369,17 +3598,20 @@ app.post('/api/premium/college-profile', authenticatePremiumUser, async (req, re
app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res) => {
const { careerProfileId } = req.query;
try {
const [rows] = await pool.query(`
SELECT *
FROM college_profiles
WHERE user_id = ?
AND career_profile_id = ?
ORDER BY created_at DESC
LIMIT 1
`, [req.id, careerProfileId]);
const [rows] = await pool.query(
`SELECT *
FROM college_profiles
WHERE user_id = ?
AND career_profile_id = ?
ORDER BY created_at DESC
LIMIT 1`,
[req.id, careerProfileId]
);
if (!rows[0]) return res.status(404).json({ error: 'No college profile for this scenario' });
res.json(rows[0]);
const row = { ...rows[0] };
delete row.user_id; // 🚫 do not ship user_id
return res.json(row);
} catch (error) {
console.error('Error fetching college profile:', error);
res.status(500).json({ error: 'Failed to fetch college profile.' });
@ -3387,32 +3619,115 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res
});
// GET every college profile for the 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 = `
SELECT cp.*,
DATE_FORMAT(cp.created_at,'%Y-%m-%d') AS created_at,
IFNULL(cpr.scenario_title, cpr.career_name) AS career_title
SELECT
cp.career_profile_id,
IFNULL(cpr.scenario_title, cpr.career_name) AS career_title,
cp.selected_school,
cp.selected_program,
cp.program_type,
DATE_FORMAT(cp.created_at,'%Y-%m-%d %H:%i:%s') AS created_at
FROM college_profiles cp
JOIN career_profiles cpr
ON cpr.id = cp.career_profile_id
AND cpr.user_id = cp.user_id
JOIN career_profiles cpr
ON cpr.id = cp.career_profile_id
AND cpr.user_id = cp.user_id
WHERE cp.user_id = ?
ORDER BY cp.created_at DESC
ORDER BY cp.created_at DESC
`;
const [rows] = await pool.query(sql, [req.id]);
const decrypted = rows.map(r => {
const row = { ...r };
const [rows] = await pool.query(sql, [req.id]);
// Whitelist shape + decrypt selected strings (no ids beyond career_profile_id)
const safe = rows.map(r => {
const out = { ...r };
for (const k of ['career_title', 'selected_school', 'selected_program']) {
const v = row[k];
const v = out[k];
if (typeof v === 'string' && v.startsWith('gcm:')) {
try { row[k] = decrypt(v); } catch {} // best-effort
try { out[k] = decrypt(v); } catch { /* best-effort */ }
}
}
return row;
return {
career_profile_id : out.career_profile_id, // needed by roadmap mapping
career_title : out.career_title,
selected_school : out.selected_school,
selected_program : out.selected_program,
program_type : out.program_type,
created_at : out.created_at
};
});
res.json({ collegeProfiles: decrypted });
return res.json({ collegeProfiles: safe });
});
app.delete('/api/premium/college-profile/by-fields', authenticatePremiumUser, async (req, res) => {
try {
const { career_title = null, selected_school = null, selected_program = null, created_at = null } = req.body || {};
if (!selected_school || !selected_program) {
return res.status(400).json({ error: 'selected_school and selected_program are required' });
}
// Pull candidates and compare after best-effort decrypt (ids never leave server)
const [rows] = await pool.query(
`SELECT cp.id,
IFNULL(cpr.scenario_title, cpr.career_name) AS career_title,
cp.selected_school, cp.selected_program,
DATE_FORMAT(cp.created_at,'%Y-%m-%d %H:%i:%s') AS created_at
FROM college_profiles cp
JOIN career_profiles cpr ON cpr.id = cp.career_profile_id AND cpr.user_id = cp.user_id
WHERE cp.user_id = ?
ORDER BY cp.created_at DESC
LIMIT 200`,
[req.id]
);
const norm = (s) => (s ?? '').toString().trim();
const want = {
career_title : norm(career_title),
selected_school: norm(selected_school),
selected_program: norm(selected_program),
created_at : norm(created_at) // optional
};
let matchId = null;
for (const r of rows) {
const row = { ...r };
for (const k of ['career_title','selected_school','selected_program']) {
const v = row[k];
if (typeof v === 'string' && v.startsWith('gcm:')) {
try { row[k] = decrypt(v); } catch {}
}
}
const sameCore = norm(row.selected_school) === want.selected_school &&
norm(row.selected_program) === want.selected_program &&
(!want.career_title || norm(row.career_title) === want.career_title);
const sameTime = !want.created_at || norm(row.created_at) === want.created_at;
if (sameCore && sameTime) { matchId = row.id; break; }
}
if (!matchId) return res.status(404).json({ error: 'not_found' });
// Cascade delete (reuse your existing logic)
const [mils] = await pool.query(
`SELECT id FROM milestones WHERE user_id=? AND career_profile_id =
(SELECT career_profile_id FROM college_profiles WHERE id=? LIMIT 1)`,
[req.id, matchId]
);
const milestoneIds = mils.map(m => m.id);
if (milestoneIds.length) {
const q = milestoneIds.map(() => '?').join(',');
await pool.query(`DELETE FROM tasks WHERE milestone_id IN (${q})`, milestoneIds);
await pool.query(`DELETE FROM milestone_impacts WHERE milestone_id IN (${q})`, milestoneIds);
await pool.query(`DELETE FROM milestones WHERE id IN (${q})`, milestoneIds);
}
await pool.query(`DELETE FROM college_profiles WHERE id=? AND user_id=?`, [matchId, req.id]);
return res.json({ ok: true });
} catch (e) {
console.error('college-profile/by-fields delete failed:', e);
return res.status(500).json({ error: 'Failed to delete college profile.' });
}
});
/* ------------------------------------------------------------------
AI-SUGGESTED MILESTONES
------------------------------------------------------------------ */
@ -3669,7 +3984,6 @@ app.post('/api/premium/tasks', authenticatePremiumUser, async (req, res) => {
const newTask = {
id: taskId,
milestone_id,
user_id: req.id,
title,
description: description || '',
due_date: due_date || null,
@ -3733,11 +4047,11 @@ app.put('/api/premium/tasks/:taskId', authenticatePremiumUser, async (req, res)
]);
const [[updatedTask]] = await pool.query(`
SELECT *
FROM tasks
WHERE id = ?
`, [taskId]);
res.json(updatedTask);
SELECT id, milestone_id, title, description, due_date, status, created_at, updated_at
FROM tasks
WHERE id = ?
`, [taskId]);
res.json(updatedTask);
} catch (err) {
console.error('Error updating task:', err);
res.status(500).json({ error: 'Failed to update task.' });
@ -4505,6 +4819,9 @@ app.post('/api/premium/reminders', authenticatePremiumUser, async (req, res) =>
}
});
// Debounce map for parallel checkout taps (key = `${userId}:${priceId}`)
const pendingCheckout = new Map();
app.post('/api/premium/stripe/create-checkout-session',
authenticatePremiumUser,
async (req, res) => {
@ -4523,15 +4840,34 @@ app.post('/api/premium/stripe/create-checkout-session',
const safeSuccess = success_url && isSafeRedirect(success_url) ? success_url : defaultSuccess;
const safeCancel = cancel_url && isSafeRedirect(cancel_url) ? cancel_url : defaultCancel;
const session = await stripe.checkout.sessions.create({
mode : 'subscription',
// 👇 Gate: if already subscribed, send to Billing Portal instead of Checkout
if (await customerHasActiveSub(customerId)) {
const portal = await stripe.billingPortal.sessions.create({
customer : customerId,
return_url : `${base}/billing?ck=portal`
});
return res.json({ url: portal.url });
}
// Otherwise, first-time subscription → Checkout (race-proof)
const key = `${req.id}:${priceId}`;
if (pendingCheckout.has(key)) {
const sess = await pendingCheckout.get(key);
return res.json({ url: sess.url });
}
const p = stripe.checkout.sessions.create({
mode : 'subscription',
customer : customerId,
line_items : [{ price: priceId, quantity: 1 }],
allow_promotion_codes : true,
success_url : safeSuccess,
cancel_url : safeCancel
allow_promotion_codes : false,
success_url : `${safeSuccess}`,
cancel_url : `${safeCancel}`
}, {
// reduce duplicate creation on rapid retries
idempotencyKey: `sub:${req.id}:${priceId}`
});
pendingCheckout.set(key, p);
const session = await p.finally(() => pendingCheckout.delete(key));
return res.json({ url: session.url });
} catch (err) {
console.error('create-checkout-session failed:', err?.raw?.message || err);
@ -4548,8 +4884,7 @@ app.get('/api/premium/stripe/customer-portal',
try {
const base = PUBLIC_BASE || `https://${req.headers.host}`;
const { return_url } = req.query;
const safeReturn = return_url && isSafeRedirect(return_url) ? return_url : `${base}/billing`;
const safeReturn = return_url && isSafeRedirect(return_url) ? return_url : `${base}/billing?ck=portal`;
const cid = await getOrCreateStripeCustomerId(req);
const portal = await stripe.billingPortal.sessions.create({

View File

@ -1,42 +1,44 @@
// shared/auth/requireAuth.js
// backend/shared/requireAuth.js
import jwt from 'jsonwebtoken';
import pool from '../config/mysqlPool.js';
const {
JWT_SECRET,
TOKEN_MAX_AGE_MS,
SESSION_COOKIE_NAME
} = process.env;
const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled
const COOKIE_NAME = SESSION_COOKIE_NAME || 'aptiva_session';
// Fallback cookie parser if cookie-parser middleware isn't present
function readSessionCookie(req) {
// Prefer cookie-parser, if installed
if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME];
// Manual parse from header
function readSessionCookie(req, cookieName) {
if (req.cookies && req.cookies[cookieName]) return req.cookies[cookieName];
const raw = req.headers.cookie || '';
for (const part of raw.split(';')) {
const [k, ...rest] = part.trim().split('=');
if (k === COOKIE_NAME) return decodeURIComponent(rest.join('='));
if (k === cookieName) return decodeURIComponent(rest.join('='));
}
return null;
}
function toMs(v) {
if (v == null) return 0;
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
if (!Number.isFinite(n) || Number.isNaN(n)) return 0;
return n < 1e12 ? n * 1000 : n; // convert seconds to ms if small
}
export async function requireAuth(req, res, next) {
try {
// 1) Try Bearer (legacy) then cookie (current)
const JWT_SECRET = process.env.JWT_SECRET;
const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || 'aptiva_session';
const MAX_AGE = Number(process.env.TOKEN_MAX_AGE_MS || 0);
if (!JWT_SECRET) {
console.error('[requireAuth] JWT_SECRET missing');
return res.status(500).json({ error: 'Server misconfig' });
}
// 1) Grab token
const authz = req.headers.authorization || '';
let token =
authz.startsWith('Bearer ')
? authz.slice(7)
: readSessionCookie(req);
const token = authz.startsWith('Bearer ')
? authz.slice(7)
: readSessionCookie(req, COOKIE_NAME);
if (!token) return res.status(401).json({ error: 'Auth required' });
// 2) Verify JWT
// 2) Verify
let payload;
try { payload = jwt.verify(token, JWT_SECRET); }
catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
@ -44,27 +46,30 @@ export async function requireAuth(req, res, next) {
const userId = payload.id;
const iatMs = (payload.iat || 0) * 1000;
// 3) Absolute max token age (optional)
// 3) Absolute max token age
if (MAX_AGE && Date.now() - iatMs > MAX_AGE) {
return res.status(401).json({ error: 'Session expired. Please sign in again.' });
}
// 4) Invalidate tokens issued before last password change
const sql = pool.raw || pool;
const [rows] = await sql.query(
'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId]
);
const changedAtMs = rows?.[0]?.password_changed_at
? new Date(rows[0].password_changed_at).getTime()
: 0;
// 4) Password change invalidation
let changedAtMs = 0;
try {
const sql = pool.raw || pool;
const [rows] = await sql.query(
'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId]
);
changedAtMs = toMs(rows?.[0]?.password_changed_at);
} catch (e) {
console.warn('[requireAuth] password_changed_at check skipped:', e?.message || e);
}
if (changedAtMs && iatMs < changedAtMs) {
return res.status(401).json({ error: 'Session invalidated. Please sign in again.' });
}
req.userId = userId;
next();
return next();
} catch (e) {
console.error('[requireAuth]', e?.message || e);
return res.status(500).json({ error: 'Server error' });

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}
DEK_PATH: ${DEK_PATH}
JWT_SECRET: ${JWT_SECRET}
APTIVA_API_BASE: ${APTIVA_API_BASE}
OPENAI_API_KEY: ${OPENAI_API_KEY}
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}

View File

@ -1,11 +1,14 @@
events {}
worker_rlimit_nofile 131072;
events { worker_connections 16384;
}
http {
keepalive_requests 10000;
include /etc/nginx/mime.types;
default_type application/octet-stream;
resolver 127.0.0.11 ipv6=off;
limit_conn_zone $binary_remote_addr zone=perip:10m;
limit_req_zone $binary_remote_addr zone=reqperip:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=reqperip:10m rate=100r/s;
set_real_ip_from 130.211.0.0/22;
set_real_ip_from 35.191.0.0/16;
real_ip_header X-Forwarded-For;
@ -13,7 +16,8 @@ http {
# ───────────── upstreams to Docker services ─────────────
upstream backend5000 { server server1:5000; } # auth & free
upstream backend5001 { server server2:5001; } # onet, distance, etc.
upstream backend5001 { server server2:5001;
keepalive 1024;} # onet, distance, etc.
upstream backend5002 { server server3:5002; } # premium
upstream gitea_backend { server gitea:3000; } # gitea service (shared network)
upstream woodpecker_backend { server woodpecker-server:8000; }
@ -33,14 +37,17 @@ http {
########################################################################
server {
listen 443 ssl;
http2 on; # modern syntax
http2 on;
http2_max_concurrent_streams 2048;
http2_idle_timeout 90s;
http2_recv_timeout 90s;
server_name dev1.aptivaai.com;
ssl_certificate /etc/letsencrypt/live/dev1.aptivaai.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dev1.aptivaai.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# ==== RUNTIME PROTECTIONS (dev test) ====
# ==== RUNTIME PROTECTIONS ====
server_tokens off;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options nosniff always;
@ -63,12 +70,6 @@ http {
proxy_buffers 8 16k;
proxy_busy_buffers_size 32k;
limit_conn perip 20; # typical users stay << 10
limit_conn_status 429; # surface as 429 Too Many Requests
limit_req zone=reqperip burst=20 nodelay;
limit_req_status 429;
if ($request_method !~ ^(GET|POST|PUT|PATCH|DELETE|OPTIONS)$) { return 405; }
if ($host !~* ^(dev1\.aptivaai\.com)$) { return 444; }
@ -89,25 +90,92 @@ http {
}
# ───── API reverseproxy rules ─────
location ^~ /api/onet/ { proxy_pass http://backend5001; }
location ^~ /api/chat/ { proxy_pass http://backend5001; proxy_http_version 1.1; proxy_buffering off; }
location ^~ /api/job-zones { proxy_pass http://backend5001; }
location ^~ /api/salary { proxy_pass http://backend5001; }
location ^~ /api/cip/ { proxy_pass http://backend5001; }
location ^~ /api/onet/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/chat/ {
limit_conn perip 10;
limit_req zone=reqperip burst=20 nodelay;
proxy_pass http://backend5001;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Connection "";
}
location ^~ /api/job-zones {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/salary {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/cip/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/projections/ {
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 90s;
proxy_connect_timeout 15s;
proxy_pass http://backend5001;
}
location ^~ /api/tuition/ { proxy_pass http://backend5001; }
location ^~ /api/projections/ { proxy_pass http://backend5001; }
location ^~ /api/skills/ { proxy_pass http://backend5001; }
location ^~ /api/maps/distance { proxy_pass http://backend5001; }
location ^~ /api/schools { proxy_pass http://backend5001; }
location ^~ /api/support { proxy_pass http://backend5001; }
location ^~ /api/support {
limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5001;
}
location ^~ /api/data/ { proxy_pass http://backend5001; }
location ^~ /api/careers/ { proxy_pass http://backend5001; }
location ^~ /api/programs/ { proxy_pass http://backend5001; }
location ^~ /api/premium/ { proxy_pass http://backend5002; }
location ^~ /api/premium/ {
limit_conn perip 10;
limit_req zone=reqperip burst=20 nodelay;
proxy_pass http://backend5002;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location ^~ /api/premium/stripe/webhook {
proxy_pass http://backend5002;
proxy_http_version 1.1;
proxy_set_header Connection "";
}
location ^~ /api/public/ { proxy_pass http://backend5002; }
location ^~ /api/ai-risk { proxy_pass http://backend5002; }
location = /api/signin { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
location = /api/register { limit_conn perip 3;
limit_req zone=reqperip burst=5 nodelay;
proxy_pass http://backend5000; }
location ^~ /api/auth/ { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
location = /api/user-profile { limit_conn perip 5;
limit_req zone=reqperip burst=10 nodelay;
proxy_pass http://backend5000; }
# General API (anything not matched above) rate-limited
location ^~ /api/ { proxy_pass http://backend5000; }
# shared proxy headers

View File

@ -31,6 +31,7 @@ import CollegeProfileForm from './components/CollegeProfileForm.js';
import CareerRoadmap from './components/CareerRoadmap.js';
import Paywall from './components/Paywall.js';
import OnboardingContainer from './components/PremiumOnboarding/OnboardingContainer.js';
import { isOnboardingInProgress } from './utils/onboardingGuard.js';
import RetirementPlanner from './components/RetirementPlanner.js';
import ResumeRewrite from './components/ResumeRewrite.js';
import LoanRepaymentPage from './components/LoanRepaymentPage.js';
@ -68,12 +69,16 @@ function App() {
const [drawerPane, setDrawerPane] = useState('support');
const [retireProps, setRetireProps] = useState(null);
const [supportOpen, setSupportOpen] = useState(false);
const [userEmail, setUserEmail] = useState('');
const [loggingOut, setLoggingOut] = useState(false);
const AUTH_HOME = '/signin-landing';
const prevPathRef = React.useRef(location.pathname);
useEffect(() => { prevPathRef.current = location.pathname; }, [location.pathname]);
const IN_OB = (p) => p.startsWith('/premium-onboarding');
/* ------------------------------------------
ChatDrawer route-aware tool handlers
------------------------------------------ */
@ -99,6 +104,40 @@ const uiToolHandlers = useMemo(() => {
return {}; // every other page exposes no UI tools
}, [pageContext]);
// route-change guard: only warn when LEAVING onboarding mid-flow
useEffect(() => {
const wasIn = IN_OB(prevPathRef.current);
const nowIn = IN_OB(location.pathname);
const leavingOnboarding = wasIn && !nowIn;
if (!leavingOnboarding) return;
// skip if not mid-flow or if final handoff set the suppress flag
if (!isOnboardingInProgress() || sessionStorage.getItem('suppressOnboardingGuard') === '1') return;
const ok = window.confirm(
"Onboarding is in progress. If you navigate away, your progress may not be saved. Continue?"
);
if (!ok) {
// bounce back to where the user was
navigate(prevPathRef.current, { replace: true });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
// browser close/refresh guard: only when currently on onboarding and mid-flow
useEffect(() => {
const onBeforeUnload = (e) => {
if (IN_OB(location.pathname)
&& isOnboardingInProgress()
&& sessionStorage.getItem('suppressOnboardingGuard') !== '1') {
e.preventDefault();
e.returnValue = "Onboarding is in progress. If you leave now, your progress may not be saved.";
}
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, [location.pathname]);
// Retirement bot is only relevant on these pages
const canShowRetireBot =
pageContext === 'RetirementPlanner' ||
@ -151,18 +190,6 @@ const showPremiumCTA = !premiumPaths.some(p =>
location.pathname.startsWith(p)
);
// Helper to see if user is 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
// ==============================
@ -188,10 +215,15 @@ if (loggingOut) return;
(async () => {
setIsLoading(true);
try {
// axios client already: withCredentials + Bearer from authMemory
const { data } = await api.get('/api/user-profile');
// Fetch only the minimal fields App needs for nav/landing/support
const { data } = await api.get('/api/user-profile?fields=firstname,is_premium,is_pro_premium');
if (cancelled) return;
setUser(data);
setUser(prev => ({
...(prev || {}),
firstname : data?.firstname || '',
is_premium : !!data?.is_premium,
is_pro_premium: !!data?.is_pro_premium,
}));
setIsAuthenticated(true);
} catch (err) {
if (cancelled) return;
@ -218,14 +250,6 @@ if (loggingOut) return;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, navigate, loggingOut]);
/* =====================
Support Modal Email
===================== */
useEffect(() => {
setUserEmail(user?.email || '');
}, [user]);
// ==========================
// 2) Logout Handler + Modal
// ==========================
@ -239,14 +263,12 @@ if (loggingOut) return;
}
};
const confirmLogout = async () => {
setLoggingOut(true);
// 1) Ask the server to clear the session cookie
try {
// If you created /logout (no /api prefix):
await api.post('api/logout'); // axios client is withCredentials: true
await api.post('/api/logout', {}); // axios client is withCredentials: true
// If your route is /api/signout instead, use:
// await api.post('/api/signout');
} catch (e) {
@ -296,8 +318,6 @@ const cancelLogout = () => {
);
}
// =====================
// Main Render / Layout
// =====================
@ -558,7 +578,6 @@ const cancelLogout = () => {
<SupportModal
open={supportOpen}
onClose={() => setSupportOpen(false)}
userEmail={userEmail}
/>
{/* 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 { Button } from './ui/button.js';
import { ProfileCtx } from '../App.js';
import api from '../auth/apiClient.js';
export default function BillingResult() {
const { setUser } = useContext(ProfileCtx) || {};
const q = new URLSearchParams(useLocation().search);
const outcome = q.get('ck'); // 'success' | 'cancel' | null
const [loading, setLoading] = useState(true);
const q = new URLSearchParams(useLocation().search);
const outcome = q.get('ck'); // 'success' | 'cancel' | 'portal' | null
/*
1) Ask the API for the latest user profile (flags, etc.)
cookies + in-mem token handled by apiClient
*/
const [loading, setLoading] = useState(true);
const [flags, setFlags] = useState({ is_premium: false, is_pro_premium: false });
// ─────────────────────────────────────────────────────────
// 1) Always fetch the latest subscription flags from backend
// ─────────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
let cancelled = false;
(async () => {
try {
const { data } = await api.get('/api/user-profile');
if (!cancelled && data && setUser) setUser(data);
const { data } = await api.get('/api/premium/subscription/status');
if (!cancelled && data) {
setFlags({
is_premium: !!data.is_premium,
is_pro_premium: !!data.is_pro_premium,
});
}
} catch (err) {
// Non-fatal here; UI still shows outcome
console.warn('[BillingResult] failed to refresh profile', err?.response?.status || err?.message);
console.warn('[BillingResult] failed to refresh flags', err?.message);
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => { cancelled = true; };
}, [setUser]);
}, []);
/*
2) UX while waiting for that roundtrip
*/
if (loading) {
return <p className="p-8 text-center">Checking your subscription</p>;
}
/*
3) Success Stripe completed the checkout flow
*/
const hasPremium = flags.is_premium || flags.is_pro_premium;
// ─────────────────────────────────────────────────────────
// 2) Success (Checkout completed)
// ─────────────────────────────────────────────────────────
if (outcome === 'success') {
return (
<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">
Premium features have been unlocked on your account.
</p>
<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 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>
</div>
);
}
/*
4) Cancelled user backed out of Stripe
*/
// ─────────────────────────────────────────────────────────
// 3) Portal return
// ─────────────────────────────────────────────────────────
if (outcome === 'portal') {
return (
<div className="max-w-md mx-auto p-8 text-center space-y-6">
<h1 className="text-2xl font-semibold">Billing updated</h1>
<p className="text-gray-600">
{hasPremium ? 'Your subscription is active.' : '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>
);
}
// ─────────────────────────────────────────────────────────
// 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">Subscription cancelled</h1>
<p className="text-gray-600">No changes were made to your account.</p>
<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="/paywall" className="block w-full">Back to pricing</Link>
<Link to="/profile">Go to my account</Link>
</Button>
{!hasPremium && (
<Button asChild className="w-full">
<Link to="/paywall">Back to pricing</Link>
</Button>
)}
</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;
try {
const r = await apiFetch(`/api/premium/career-profile/${id}`, {
const r = await apiFetch(`/api/premium/career-profile/by-fields`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
scenario_title: row.scenario_title || null,
career_name : row.career_name || null,
start_date : row.start_date || null
})
});
if (!r.ok) {
// 401/403 will already be handled by apiFetch
@ -34,7 +40,13 @@ const nav = useNavigate();
alert(msg || 'Failed to delete');
return;
}
setRows(prev => prev.filter(row => row.id !== id));
setRows(prev => prev.filter(x =>
!(
(x.scenario_title || '') === (row.scenario_title || '') &&
(x.career_name || '') === (row.career_name || '') &&
(x.start_date || '') === (row.start_date || '')
)
));
} catch (e) {
console.error('Delete failed:', e);
alert('Failed to delete');
@ -69,13 +81,11 @@ const nav = useNavigate();
<td className="p-2">{r.start_date}</td>
<td className="p-2 space-x-2">
<Link
to={`/profile/careers/${r.id}/edit`}
to={`/profile/careers/${encodeURIComponent(r.id)}/edit`}
className="underline text-blue-600"
>
edit
</Link>
>edit</Link>
<button
onClick={() => remove(r.id)}
onClick={() => remove(r)}
className="text-red-600 underline"
>
delete
@ -85,8 +95,21 @@ const nav = useNavigate();
))}
{rows.length === 0 && (
<tr>
<td colSpan={5} className="p-4 text-center text-gray-500">
No career profiles yet
<td colSpan={5} className="p-6">
<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>
</tr>
)}

View File

@ -345,7 +345,6 @@ export default function CareerRoadmap({ selectedCareer: initialCareer }) {
// Basic states
const [userProfile, setUserProfile] = useState(null);
const [financialProfile, setFinancialProfile] = useState(null);
const [masterCareerRatings, setMasterCareerRatings] = useState([]);
const [existingCareerProfiles, setExistingCareerProfiles] = useState([]);
const [selectedCareer, setSelectedCareer] = useState(initialCareer || null);
const [careerProfileId, setCareerProfileId] = useState(null);
@ -527,6 +526,7 @@ useEffect(() => {
if (location.state?.fromOnboarding) {
modalGuard.current.skip = true; // suppress once
window.history.replaceState({}, '', location.pathname);
sessionStorage.removeItem('suppressOnboardingGuard');
}
}, [location.state, location.pathname]);
@ -535,7 +535,7 @@ useEffect(() => {
* ------------------------------------------------------------*/
useEffect(() => {
(async () => {
const up = await authFetch('/api/user-profile');
const up = await authFetch('/api/user-profile?fields=area,state');
if (up.ok && (up.headers.get('content-type')||'').includes('application/json')) {
setUserProfile(await up.json());
}
@ -691,20 +691,51 @@ useEffect(() => {
}
}, [recommendations]);
// 2) load JSON => masterCareerRatings
useEffect(() => {
(async () => {
try {
const { data } = await api.get('/api/data/careers-with-ratings');
setMasterCareerRatings(data || []);
} catch (err) {
console.error('Error loading career ratings via API =>', err);
setMasterCareerRatings([]);
// ─────────────────────────────────────────────────────────────
// Resolve SOC without shipping any bulk JSON to the browser
// Order: scenarioRow.soc_code → selectedCareer.code → resolve by title
// ─────────────────────────────────────────────────────────────
const resolveSoc = useCallback(async () => {
// 1) scenarioRow already has it?
const fromScenario = scenarioRow?.soc_code || scenarioRow?.socCode;
if (fromScenario) {
setFullSocCode(fromScenario);
setStrippedSocCode(stripSocCode(fromScenario));
return;
}
})();
}, []);
// 2) selectedCareer from onboarding/search
const fromSelected = selectedCareer?.code || selectedCareer?.soc_code || selectedCareer?.socCode;
if (fromSelected) {
setFullSocCode(fromSelected);
setStrippedSocCode(stripSocCode(fromSelected));
return;
}
// 3) Fallback: resolve via tiny title→SOC endpoint (no bulk dataset)
const title = (scenarioRow?.career_name || '').trim();
if (!title) {
setFullSocCode(null);
setStrippedSocCode(null);
return;
}
try {
const r = await authFetch(`/api/careers/resolve?title=${encodeURIComponent(title)}`);
if (r.ok && (r.headers.get('content-type')||'').includes('application/json')) {
const j = await r.json(); // { title, soc_code, ... }
if (j?.soc_code) {
setFullSocCode(j.soc_code);
setStrippedSocCode(stripSocCode(j.soc_code));
return;
}
}
// not found
setFullSocCode(null);
setStrippedSocCode(null);
} catch (e) {
console.error('SOC resolve failed', e);
setFullSocCode(null);
setStrippedSocCode(null);
}
}, [scenarioRow, selectedCareer]);
// 3) fetch users career-profiles
// utilities you already have in this file
@ -889,27 +920,6 @@ const refetchScenario = useCallback(async () => {
if (r.ok) setScenarioRow(await r.json());
}, [careerProfileId]);
// 5) from scenarioRow => find the full SOC => strip
useEffect(() => {
if (!scenarioRow?.career_name || !masterCareerRatings.length) {
setStrippedSocCode(null);
setFullSocCode(null);
return;
}
const target = normalizeTitle(scenarioRow.career_name);
const found = masterCareerRatings.find(
(obj) => normalizeTitle(obj.title || '') === target
);
if (!found) {
console.warn('No matching SOC =>', scenarioRow.career_name);
setStrippedSocCode(null);
setFullSocCode(null);
return;
}
setStrippedSocCode(stripSocCode(found.soc_code));
setFullSocCode(found.soc_code);
}, [scenarioRow, masterCareerRatings]);
useEffect(() => {
if (!fullSocCode || !scenarioRow || scenarioRow.riskLevel) return;
(async () => {
@ -931,6 +941,8 @@ const refetchScenario = useCallback(async () => {
})();
}, [fullSocCode, scenarioRow]);
useEffect(() => { resolveSoc(); }, [resolveSoc]);
async function fetchAiRisk(socCode, careerName, description, tasks) {
let aiRisk = null;

View File

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

View File

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

View File

@ -64,7 +64,7 @@ const InterestInventory = () => {
const fetchUserProfile = async () => {
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');
const data = await res.json();
setUserProfile(data);
@ -135,19 +135,10 @@ const InterestInventory = () => {
// First save the answers to user profile
try {
await authFetch('/api/user-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
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,
}),
});
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ interest_inventory_answers: answers }),
});
} catch (err) {
console.error('Error saving answers to user profile:', err.message);
}

View File

@ -1,5 +1,5 @@
// CareerOnboarding.js
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
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');
} catch { return null; }
});
const [currentlyWorking, setCurrentlyWorking] = useState('');
const [collegeStatus, setCollegeStatus] = useState('');
const [currentlyWorking, setCurrentlyWorking] = useState(data.currently_working || '');
const [collegeStatus, setCollegeStatus] = useState(data.college_enrollment_status || '');
const [showFinPrompt, setShowFinPrompt] = useState(false);
const finPromptShownRef = useRef(false);
/* ── 2. derived helpers ───────────────────────────────────── */
const selectedCareerTitle = careerObj?.title || '';
@ -39,24 +40,62 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
if (!navCareerObj?.title) return;
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 => ({
...prev,
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
}, [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
const handleChange = (e) => {
setData(prev => ({ ...prev, [e.target.name]: e.target.value }));
const k = e.target.name;
if (['status','start_date','career_goals'].includes(k)) {
saveDraft({ careerData: { [k]: e.target.value } }).catch(() => {});
}
};
setData(prev => ({ ...prev, [e.target.name]: e.target.value }));
const k = e.target.name;
if (['status','start_date'].includes(k)) {
saveDraft({ careerData: { [k]: e.target.value } }).catch(() => {});
}
};
/* ── 4. callbacks ─────────────────────────────────────────── */
function handleCareerSelected(career) {
@ -168,10 +207,9 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
value={collegeStatus}
onChange={(e) => {
const val = e.target.value;
setCurrentlyWorking(val);
setData(prev => ({ ...prev, currently_working: val }));
// persist immediately
saveDraft({ careerData: { currently_working: val } }).catch(() => {});
setCollegeStatus(val);
setData(prev => ({ ...prev, college_enrollment_status: val }));
saveDraft({ careerData: { college_enrollment_status: val } }).catch(() => {});
}}
required
className="w-full border rounded p-2"
@ -195,6 +233,7 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
onClick={() => {
// user explicitly wants to enter financials now
setData(prev => ({ ...prev, skipFinancialStep: false }));
saveDraft({ careerData: { skipFinancialStep: false } }).catch(() => {});
setShowFinPrompt(false);
}}
>
@ -206,7 +245,7 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
onClick={() => {
/* mark intent to skip the finance step */
setData(prev => ({ ...prev, skipFinancialStep: true }));
saveDraft({ careerData: { skipFinancialStep: true } }).catch(() => {});
setShowFinPrompt(false); // hide the prompt, stay on page
}}
>
@ -222,6 +261,12 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
name="career_goals"
value={data.career_goals || ''}
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"
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;
const [selectedSchool, setSelectedSchool] = useState(() => {
if (navSelectedSchoolObj && typeof navSelectedSchoolObj === 'object') {
return { INSTNM: navSelectedSchoolObj.INSTNM,
CIPDESC: navSelectedSchoolObj.CIPDESC || '',
CREDDESC: navSelectedSchoolObj.CREDDESC || '' };
}
if (navSelectedSchoolObj) {
const name = toSchoolName(navSelectedSchoolObj);
return {
INSTNM: name,
CIPDESC: navSelectedSchoolObj.CIPDESC || navSelectedSchoolObj.program || '',
CREDDESC: navSelectedSchoolObj.CREDDESC || navSelectedSchoolObj.programType || '',
UNITID: navSelectedSchoolObj.UNITID || navSelectedSchoolObj.unitId || null
};
}
if (data.selected_school) {
return { INSTNM: data.selected_school,
CIPDESC: data.selected_program || '',
CREDDESC: data.program_type || '' };
return {
INSTNM: data.selected_school,
CIPDESC: data.selected_program || '',
CREDDESC: data.program_type || '',
UNITID: null
};
}
return null;
});
@ -58,9 +66,9 @@ function toSchoolName(objOrStr) {
// Destructure parent data
const {
college_enrollment_status = '',
selected_school = '',
selected_program = '',
program_type = '',
selected_school: top_selected_school = '',
selected_program: top_selected_program = '',
program_type: top_program_type = '',
academic_calendar = 'semester',
annual_financial_aid = '',
is_online = false,
@ -80,6 +88,10 @@ function toSchoolName(objOrStr) {
tuition_paid = '',
} = 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
const [manualTuition, setManualTuition] = useState('');
const [autoTuition, setAutoTuition] = useState(0);
@ -100,40 +112,93 @@ function toSchoolName(objOrStr) {
}
}, [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(() => {
// if props already have values, do nothing
if (data?.selected_school || data?.selected_program || data?.program_type) return;
if (selectedSchool?.UNITID && !selectedUnitId) {
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;
(async () => {
let draft;
try { draft = await loadDraft(); } catch { draft = null; }
const cd = draft?.data?.collegeData;
if (!cd) return;
if (cancelled) return;
if (!cd || cancelled) return;
// 1) write into parent data (so inputs prefill)
setData(prev => ({
...prev,
selected_school : cd.selected_school ?? prev.selected_school ?? '',
selected_program: cd.selected_program ?? prev.selected_program ?? '',
program_type : cd.program_type ?? prev.program_type ?? ''
selected_school : prev.selected_school || cd.selected_school || '',
selected_program: prev.selected_program || cd.selected_program || '',
program_type : prev.program_type || cd.program_type || ''
}));
// 2) set local selectedSchool object (triggers your selectedSchool→data effect too)
setSelectedSchool({
INSTNM : cd.selected_school || '',
CIPDESC : cd.selected_program || '',
CREDDESC: cd.program_type || ''
});
// Reflect into local selectedSchool (keeps your existing flow intact)
setSelectedSchool(prev => ({
INSTNM : (prev?.INSTNM ?? '') || cd.selected_school || '',
CIPDESC : (prev?.CIPDESC ?? '') || cd.selected_program || '',
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; };
// run once on mount; we don't want to fight subsequent user edits
// 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(() => {
if (data.expected_graduation && !expectedGraduation)
setExpectedGraduation(data.expected_graduation);
@ -237,7 +302,7 @@ useEffect(() => {
setSchoolSuggestions([]);
setProgramSuggestions([]);
setAvailableProgramTypes([]);
saveDraft({ collegeData: { selected_school: name } }).catch(() => {});
saveDraft({ collegeData: { selected_school: name, unit_id: uid } }).catch(() => {});
};
// Program
@ -344,15 +409,15 @@ useEffect(() => {
]);
useEffect(() => {
const hasSchool = !!data.selected_school;
const hasAnyProgram = !!data.selected_program || !!data.program_type;
const hasSchool = !!selected_school;
const hasAnyProgram = !!selected_program || !!program_type;
if (!hasSchool && !hasAnyProgram) return;
setSelectedSchool(prev => {
const next = {
INSTNM : data.selected_school || '',
CIPDESC : data.selected_program || '',
CREDDESC: data.program_type || ''
INSTNM : selected_school || '',
CIPDESC : selected_program || '',
CREDDESC: program_type || ''
};
// avoid useless state churn
if (prev &&
@ -361,7 +426,7 @@ useEffect(() => {
prev.CREDDESC=== next.CREDDESC) return prev;
return next;
});
}, [data.selected_school, data.selected_program, data.program_type]);
}, [selected_school, selected_program, program_type]);
/* ------------------------------------------------------------------ */
/* Whenever the user changes enrollmentDate OR programLength */
@ -415,6 +480,12 @@ useEffect(() => {
program_length: chosenProgramLength
}));
saveDraft({ collegeData: {
tuition: Number(chosenTuition) || 0,
program_length: Number(chosenProgramLength) || 0,
expected_graduation: expectedGraduation || ''
}}).catch(()=>{});
nextStep();
};
@ -456,8 +527,10 @@ const ready =
name="is_in_district"
checked={is_in_district}
onChange={handleParentFieldChange}
onBlur={()=>saveDraft({collegeData:{is_in_district}}).catch(()=>{})}
className="h-4 w-4"
/>
<label className="font-medium">In District? {infoIcon("Used by Community Colleges usually - local discounts")}</label>
</div>
@ -467,6 +540,7 @@ const ready =
name="is_in_state"
checked={is_in_state}
onChange={handleParentFieldChange}
onBlur={()=>saveDraft({collegeData:{is_in_state}}).catch(()=>{})}
className="h-4 w-4"
/>
<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"
checked={is_online}
onChange={handleParentFieldChange}
onBlur={()=>saveDraft({collegeData:{is_online}}).catch(()=>{})}
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>
@ -489,6 +564,7 @@ const ready =
name="loan_deferral_until_graduation"
checked={loan_deferral_until_graduation}
onChange={handleParentFieldChange}
onBlur={()=>saveDraft({collegeData:{loan_deferral_until_graduation}}).catch(()=>{})}
className="h-4 w-4"
/>
<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}
onChange={handleParentFieldChange}
placeholder="e.g. 120"
onBlur={(e)=>saveDraft({collegeData:{credit_hours_required: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
</div>
@ -603,6 +680,7 @@ const ready =
value={credit_hours_per_year}
onChange={handleParentFieldChange}
placeholder="e.g. 24"
onBlur={(e)=>saveDraft({collegeData:{credit_hours_per_year: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
</div>
@ -629,6 +707,7 @@ const ready =
value={annual_financial_aid}
onChange={handleParentFieldChange}
placeholder="e.g. 2000"
onBlur={(e)=>saveDraft({collegeData:{annual_financial_aid: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
<button
@ -649,6 +728,7 @@ const ready =
value={existing_college_debt}
onChange={handleParentFieldChange}
placeholder="e.g. 2000"
onBlur={(e)=>saveDraft({collegeData:{existing_college_debt: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
</div>
@ -680,6 +760,7 @@ const ready =
value={hours_completed}
onChange={handleParentFieldChange}
placeholder="Credit hours done"
onBlur={(e)=>saveDraft({collegeData:{hours_completed: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
</div>
@ -700,6 +781,7 @@ const ready =
setEnrollmentDate(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"
required
/>
@ -726,6 +808,7 @@ const ready =
setExpectedGraduation(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"
required
/>
@ -741,6 +824,7 @@ const ready =
value={interest_rate}
onChange={handleParentFieldChange}
placeholder="e.g. 5.5"
onBlur={(e)=>saveDraft({collegeData:{interest_rate: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
</div>
@ -753,6 +837,7 @@ const ready =
value={loan_term}
onChange={handleParentFieldChange}
placeholder="e.g. 10"
onBlur={(e)=>saveDraft({collegeData:{loan_term: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
</div>
@ -765,6 +850,7 @@ const ready =
value={extra_payment}
onChange={handleParentFieldChange}
placeholder="Optional"
onBlur={(e)=>saveDraft({collegeData:{extra_payment: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
</div>
@ -777,6 +863,7 @@ const ready =
value={expected_salary}
onChange={handleParentFieldChange}
placeholder="e.g. 65000"
onBlur={(e)=>saveDraft({collegeData:{expected_salary: parseFloat(e.target.value)||0}}).catch(()=>{})}
className="w-full border rounded p-2"
/>
</div>
@ -808,11 +895,9 @@ const ready =
{showAidWizard && (
<Modal onClose={() => setShowAidWizard(false)}>
<FinancialAidWizard
onAidEstimated={(estimate) => {
setData(prev => ({
...prev,
annual_financial_aid: estimate
}));
onAidEstimated={(estimate) => {
setData(prev => ({ ...prev, annual_financial_aid: estimate }));
saveDraft({ collegeData: { annual_financial_aid: estimate } }).catch(() => {});
}}
onClose={() => setShowAidWizard(false)}
/>

View File

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

View File

@ -8,6 +8,7 @@ import CollegeOnboarding from './CollegeOnboarding.js';
import ReviewPage from './ReviewPage.js';
import { loadDraft, saveDraft, clearDraft } from '../../utils/onboardingDraftApi.js';
import authFetch from '../../utils/authFetch.js';
import { isOnboardingInProgress } from '../../utils/onboardingGuard.js';
const POINTER_KEY = 'premiumOnboardingPointer';
@ -28,30 +29,6 @@ export default function OnboardingContainer() {
useEffect(() => {
(async () => {
// 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
try {
@ -73,6 +50,14 @@ export default function OnboardingContainer() {
setCareerData(d.careerData || {});
setFinancialData(d.financialData || {});
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 {
// no server draft yet: seed with minimal data from pointer/local selectedCareer
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,
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);
})();
}, [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(() => {
if (!loaded) return;
if (!loaded) return;
const t = setTimeout(async () => {
// persist server draft (all sensitive data)
const resp = await saveDraft({
id: ptrRef.current.id,
step,
careerData,
financialData,
collegeData
});
// update pointer (safe)
const pointer = {
id: resp.id,
step,
skipFin: !!careerData.skipFinancialStep,
selectedCareer: (careerData.career_name || careerData.soc_code)
? { title: careerData.career_name, soc_code: careerData.soc_code }
: JSON.parse(localStorage.getItem('selectedCareer') || 'null')
};
ptrRef.current = pointer;
localStorage.setItem(POINTER_KEY, JSON.stringify(pointer));
}, 400); // debounce
const t = setTimeout(async () => {
const cj = JSON.stringify(careerData);
const fj = JSON.stringify(financialData);
const col = JSON.stringify(collegeData);
const nonEmpty = (s) => s && s !== '{}' && s !== 'null';
const changedCareer = cj !== prevCareerJsonRef.current && nonEmpty(cj);
const changedFinancial = fj !== prevFinancialJsonRef.current && nonEmpty(fj);
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 = {
id: ptrRef.current.id,
step,
skipFin: !!careerData.skipFinancialStep,
selectedCareer: (careerData.career_name || careerData.soc_code)
? { title: careerData.career_name, soc_code: careerData.soc_code }
: JSON.parse(localStorage.getItem('selectedCareer') || 'null'),
};
ptrRef.current = pointer;
localStorage.setItem(POINTER_KEY, JSON.stringify(pointer));
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);
}, [loaded, step, careerData, financialData, collegeData]);
return () => clearTimeout(t);
}, [loaded, step, careerData, financialData, collegeData]);
// ---- nav helpers ------------------------------------------------
const nextStep = () => setStep((s) => s + 1);
@ -190,7 +213,7 @@ export default function OnboardingContainer() {
// 🔐 cleanup: remove server draft + pointer
await clearDraft();
localStorage.removeItem(POINTER_KEY);
sessionStorage.setItem('suppressOnboardingGuard', '1');
navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } });
} catch (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 }) {
const navigate = useNavigate();
const { setFinancialProfile, setScenario } = useContext(ProfileCtx);
const { setScenario } = useContext(ProfileCtx);
const usernameRef = useRef('');
const passwordRef = useRef('');
const [error, setError] = useState('');
@ -55,20 +55,27 @@ function SignIn({ setIsAuthenticated, setUser }) {
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
// Optional: keep allowlisted id if provided by API
if (data.id) localStorage.setItem('id', data.id);
// Fetch ONLY the minimal fields needed for the landing UI
// (requires server1 to honor ?fields=… — otherwise remove `fields=` until its enabled)
let minimalUser = { firstname: '' };
try {
const minimalRes = await fetch('/api/user-profile?fields=firstname', {
credentials: 'include',
});
if (minimalRes.ok) {
const j = await minimalRes.json();
minimalUser = {
firstname: j.firstname || '',
is_premium: j.is_premium ?? 0,
is_pro_premium: j.is_pro_premium ?? 0,
};
}
} catch (_) {}
// Load user profile for app state; cookie is sent automatically
const profileRes = await fetch('/api/user-profile', {
credentials: 'include',
});
if (!profileRes.ok) throw new Error('Failed to load profile');
const profile = await profileRes.json();
setFinancialProfile(profile);
setScenario(null);
setIsAuthenticated(true);
setUser(data.user || null);
// 3) Establish app auth state with minimal user info
setScenario(null);
setIsAuthenticated(true);
setUser(minimalUser);
navigate('/signin-landing');
} catch (err) {

View File

@ -9,9 +9,9 @@ function SignInLanding({ user }) {
Welcome to AptivaAI {user?.firstname}!
</h2>
<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>
<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>
@ -24,15 +24,15 @@ We blend data-backed insights with human-centered design, giving you practical r
</p>
<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">
Go to Planning
Go to Exploring
</Link>
<Link to="/preparing" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
Go to Preparing
</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
</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
</Link>
</div>

View File

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

View File

@ -77,7 +77,14 @@ function UserProfile() {
useEffect(() => {
(async () => {
try {
const res = await authFetch('/api/user-profile', { method: 'GET' });
const res = await authFetch('/api/user-profile?fields=' +
[
'firstname','lastname','email',
'zipcode','state','area','career_situation',
'phone_e164','sms_opt_in'
].join(','),
{ method: 'GET' }
);
if (!res || !res.ok) return;
const data = await res.json();

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(/\/+$/, '');
const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`;
// Always same-origin so the session cookie goes with it
const DRAFT_URL = '/api/premium/onboarding/draft';
export async function loadDraft() {
const res = await authFetch(DRAFT_URL);
if (!res) return null; // session expired
if (res.status === 404) return null;
if (!res.ok) throw new Error(`loadDraft ${res.status}`);
return res.json(); // null or { id, step, data }
}
export async function loadDraft() {
const res = await authFetch(DRAFT_URL);
if (!res) return null; // session expired
if (res.status === 404) return null;
if (!res.ok) throw new Error(`loadDraft ${res.status}`);
return res.json(); // null or { id, step, data }
}
/**
* saveDraft(input)
* Accepts either:
* - { id, step, data } // full envelope
* - { id, step, careerData?, financialData?, collegeData? } // partial sections
* Merges partials with the current server draft to avoid clobbering.
* Merges partials with the current server draft. Never POSTs an empty data blob.
*/
export async function saveDraft(input = {}) {
// Normalize inputs
let { id = null, step = 0, data } = input;
// If caller passed sections (careerData / financialData / collegeData) instead of a 'data' envelope,
// merge them into the existing server draft so we don't drop other sections.
// Treat {} like "no data provided" so we hit the merge/guard path below.
const isEmptyObject =
data && typeof data === 'object' && !Array.isArray(data) && Object.keys(data).length === 0;
if (isEmptyObject) data = null;
if (data == null) {
// Load existing draft (may be null/404 for first-time)
// Merge any section patches with existing draft; skip POST if literally nothing to save.
let existing = null;
try { existing = await loadDraft(); } catch (_) {}
try { existing = await loadDraft(); } catch {}
const existingData = (existing && existing.data) || {};
const has = (k) => Object.prototype.hasOwnProperty.call(input, k);
@ -36,12 +40,32 @@ export async function saveDraft(input = {}) {
if (has('financialData')) patch.financialData = input.financialData;
if (has('collegeData')) patch.collegeData = input.collegeData;
const hasPatch =
Object.prototype.hasOwnProperty.call(patch, 'careerData') ||
Object.prototype.hasOwnProperty.call(patch, 'financialData') ||
Object.prototype.hasOwnProperty.call(patch, 'collegeData');
// If no patch and no existing draft, there is nothing to persist → NO POST.
if (!hasPatch && !existing) {
return { id: null, step: step ?? 0 };
}
// If no patch but we do have an existing draft, only bump step locally (no clobber).
if (!hasPatch && existing) {
return { id: existing.id, step: step ?? existing.step ?? 0 };
}
// Normal case: merge existing + patch
data = { ...existingData, ...patch };
// Prefer caller's id/step when provided; otherwise reuse existing
if (id == null && existing?.id != null) id = existing.id;
if (step == null && existing?.step != null) step = existing.step;
}
// Final guard: if data is still empty, do not POST.
if (!data || (typeof data === 'object' && Object.keys(data).length === 0)) {
return { id: id || null, step: step ?? 0 };
}
const res = await authFetch(DRAFT_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@ -52,9 +76,9 @@ export async function saveDraft(input = {}) {
return res.json(); // { id, step }
}
export async function clearDraft() {
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
if (!res) return false;
if (!res.ok) throw new Error(`clearDraft ${res.status}`);
return true; // server returns { ok: true }
}
export async function clearDraft() {
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
if (!res) return false;
if (!res.ok) throw new Error(`clearDraft ${res.status}`);
return true;
}

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"