Compare commits

...

5 Commits

Author SHA1 Message Date
666427a7c9 Added email/phone verification
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2025-09-12 09:36:00 +00:00
a2a2d9b558 Fixed onboardingdraft for School/Program. Added pointer removal in App.js after navigate. Altered server3 draft body checks. 2025-09-11 17:02:13 +00:00
4a2aaedf63 Added Premium Tests - fixed CollegeProfileForm.js 2025-09-11 10:56:03 +00:00
375e9bacdc Testing and minor changes to CareerExplorer, CollegeProfileForm fixes. 2025-09-10 12:15:36 +00:00
e943f1c427 Network tab hides, Reload Career Suggestion performance 2025-09-05 16:18:33 +00:00
151 changed files with 9831 additions and 1872 deletions

View File

@ -1 +1 @@
fb83dd6424562765662889aea6436fdb4b1b975f-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
b5aad6117f63426726be6ae9a07e5aaa938f14ff-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b

View File

@ -30,3 +30,10 @@ coverage
*.crt
*.pfx
# Test sources & artifacts
tests/
playwright-report/
test-results/
blob-report/
*.trace.zip

8
.env
View File

@ -1,8 +0,0 @@
CORS_ALLOWED_ORIGINS=https://dev1.aptivaai.com,http://34.16.120.118:3000,http://localhost:3000
SERVER1_PORT=5000
SERVER2_PORT=5001
SERVER3_PORT=5002
IMG_TAG=ed1fdbb-202508121553
ENV_NAME=dev
PROJECT=aptivaai-dev

1
.gitignore vendored
View File

@ -26,3 +26,4 @@ uploads/.env
.env
.env.*
scan-env.sh
.aptiva-test-user.json

View File

@ -1 +1 @@
1a7fe9191922c4f8389027ed53b6a4909740a48b
98f674eca26e366aee0b41f250978982060105f0

View File

@ -1 +1 @@
1a7fe9191922c4f8389027ed53b6a4909740a48b
98f674eca26e366aee0b41f250978982060105f0

View File

@ -16,6 +16,7 @@ import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js';
import cookieParser from 'cookie-parser';
import { sendSMS } from './utils/smsService.js';
const CANARY_SQL = `
CREATE TABLE IF NOT EXISTS encryption_canary (
@ -507,6 +508,28 @@ const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
const PASSWORD_HELP =
'Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character (!@#$%^&*).';
/*
Verification helpers / rate limits
---------------------------------------------------------------- */
function absoluteWebBase() {
// You already require APTIVA_API_BASE above; reuse it to build magic links.
return String(RESET_CONFIG.BASE_URL || '').replace(/\/+$/, '');
}
const verifySendLimiter = rateLimit({
windowMs: 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
const verifyConfirmLimiter = rateLimit({
windowMs: 60 * 1000,
max: 6,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
// Change password (must be logged in)
app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, async (req, res) => {
try {
@ -706,6 +729,133 @@ app.post('/api/auth/password-reset/confirm', pwConfirmLimiter, async (req, res)
}
});
/* ------------------------------------------------------------------
AUTH STATUS (used by client gate)
------------------------------------------------------------------ */
app.get('/api/auth/status', requireAuth, async (req, res) => {
try {
const uid = req.userId;
const [rows] = await (pool.raw || pool).query(
'SELECT email_verified_at, phone_verified_at FROM user_profile WHERE id = ? LIMIT 1',
[uid]
);
const row = rows?.[0] || {};
return res.status(200).json({
is_authenticated: true,
email_verified_at: row.email_verified_at || null,
phone_verified_at: row.phone_verified_at || null
});
} catch (e) {
console.error('[auth/status]', e?.message || e);
return res.status(500).json({ error: 'Server error' });
}
});
/* ------------------------------------------------------------------
EMAIL VERIFICATION (send + confirm)
------------------------------------------------------------------ */
app.post('/api/auth/verify/email/send', requireAuth, verifySendLimiter, async (req, res) => {
try {
if (!SENDGRID_ENABLED) return res.status(503).json({ error: 'Email not configured' });
const uid = req.userId;
const [[row]] = await (pool.raw || pool).query('SELECT email FROM user_profile WHERE id=? LIMIT 1', [uid]);
const enc = row?.email;
if (!enc) return res.status(400).json({ error: 'No email on file' });
let emailPlain = '';
try { emailPlain = decrypt(enc); } catch { emailPlain = enc; }
const token = jwt.sign({ sub: String(uid), prp: 'verify_email' }, JWT_SECRET, { expiresIn: '30m' });
const link = `${absoluteWebBase()}/verify?t=${encodeURIComponent(token)}`;
const text =
`Verify your AptivaAI email by clicking the link below (expires in 30 minutes):
+${link}`;
await sgMail.send({
to: emailPlain,
from: RESET_CONFIG.FROM,
subject: 'Verify your email — AptivaAI',
text,
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
});
return res.status(200).json({ ok: true });
} catch (e) {
console.error('[verify/email/send]', e?.message || e);
return res.status(500).json({ error: 'Failed to send verification email' });
}
});
app.post('/api/auth/verify/email/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => {
try {
const { token } = req.body || {};
if (!token) return res.status(400).json({ error: 'Token required' });
let payload;
try { payload = jwt.verify(token, JWT_SECRET); }
catch { return res.status(400).json({ error: 'Invalid or expired token' }); }
if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_email') {
return res.status(400).json({ error: 'Token/user mismatch' });
}
const [r] = await (pool.raw || pool).query(
'UPDATE user_profile SET email_verified_at = UTC_TIMESTAMP() WHERE id = ?',
[req.userId]
);
return res.status(200).json({ ok: !!r?.affectedRows });
} catch (e) {
console.error('[verify/email/confirm]', e?.message || e);
return res.status(500).json({ error: 'Failed to confirm email' });
}
});
/* ------------------------------------------------------------------
PHONE VERIFICATION (send + confirm) optional
------------------------------------------------------------------ */
app.post('/api/auth/verify/phone/send', requireAuth, verifySendLimiter, async (req, res) => {
try {
const { phone_e164 } = req.body || {};
if (!phone_e164 || !/^\+1\d{10}$/.test(phone_e164)) {
return res.status(400).json({ error: 'Phone must be +1 followed by 10 digits' });
}
// persist/overwrite phone on file
await (pool.raw || pool).query('UPDATE user_profile SET phone_e164=? WHERE id=?', [phone_e164, req.userId]);
const code = String(Math.floor(100000 + Math.random() * 900000));
await sendSMS({ to: phone_e164, body: `AptivaAI security code: ${code}. Expires in 10 minutes.` });
// store short-lived challenge in HttpOnly cookie (10 min)
const tok = jwt.sign({ sub: String(req.userId), prp: 'verify_phone', code }, JWT_SECRET, { expiresIn: '10m' });
res.cookie('aptiva_phone_vc', tok, { ...sessionCookieOptions(), maxAge: 10 * 60 * 1000 });
return res.status(200).json({ ok: true });
} catch (e) {
console.error('[verify/phone/send]', e?.message || e);
return res.status(500).json({ error: 'Failed to send code' });
}
});
app.post('/api/auth/verify/phone/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => {
try {
const { code } = req.body || {};
if (!code) return res.status(400).json({ error: 'Code required' });
const tok = req.cookies?.aptiva_phone_vc;
if (!tok) return res.status(400).json({ error: 'No challenge issued' });
let payload;
try { payload = jwt.verify(tok, JWT_SECRET); }
catch { return res.status(400).json({ error: 'Challenge expired' }); }
if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_phone') {
return res.status(400).json({ error: 'Challenge mismatch' });
}
if (String(payload?.code) !== String(code)) {
return res.status(400).json({ error: 'Invalid code' });
}
const [r] = await (pool.raw || pool).query(
'UPDATE user_profile SET phone_verified_at = UTC_TIMESTAMP() WHERE id = ?',
[req.userId]
);
res.clearCookie('aptiva_phone_vc', sessionCookieOptions());
return res.status(200).json({ ok: !!r?.affectedRows });
} catch (e) {
console.error('[verify/phone/confirm]', e?.message || e);
return res.status(500).json({ error: 'Failed to confirm phone' });
}
});
/* ------------------------------------------------------------------
USER REGISTRATION (MySQL)
@ -787,78 +937,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 +1144,72 @@ 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',
'email_verified_at',
'phone_verified_at'
]);
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,
@ -555,34 +580,9 @@ async function storeRiskAnalysisInDB({
}
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=?',
[req.id]
);
return res.json(row || null);
});
// POST upsert draft
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 });
});
// 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]);
res.json({ ok: true });
});
//Stripe webhook endpoint (raw body)
// 1) Raw body parser (must be before express.json)
app.post(
'/api/premium/stripe/webhook',
express.raw({ type: 'application/json' }),
@ -598,32 +598,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:
@ -633,11 +674,153 @@ app.post(
}
);
// 2) Basic middlewares
app.use(helmet({ contentSecurityPolicy:false, crossOriginEmbedderPolicy:false }));
//leave below Stripe webhook
app.use(express.json({ limit: '5mb' }));
//*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=?
ORDER BY updated_at DESC, id DESC
LIMIT 1`,
[req.id]
);
return res.json(row || null);
});
// POST upsert draft (ID-agnostic, partial merge, 1 draft per user)
app.post('/api/premium/onboarding/draft', authenticatePremiumUser, async (req, res) => {
try {
// ---- 0) Harden req.body and incoming shapes (accept object *or* JSON string)
let body = {};
if (req && req.body != null) {
if (typeof req.body === 'object') {
body = req.body;
} else if (typeof req.body === 'string') {
try { body = JSON.parse(req.body); } catch { 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) Only reject when there's truly no incoming content AND no prior draft
const isPlainObj = (o) => o && typeof o === 'object' && !Array.isArray(o);
const isEmptyObj = (o) => isPlainObj(o) && Object.keys(o).length === 0;
const hasIncoming = isPlainObj(incoming) && !isEmptyObj(incoming);
const hasPrior = !!base && isPlainObj(prev) && !isEmptyObj(prev);
if (!hasIncoming && !hasPrior) {
return res.status(400).json({ error: 'empty_draft' });
}
// ---- 4) Final id/step and upsert
const draftId = base?.id || id || uuidv4();
const finalStep = Number.isInteger(step) ? step : (parseInt(step,10) || base?.step || 0);
console.log('[draft-upsert]', {
userId : req.id,
draftId : draftId,
step : finalStep,
incoming : Object.keys(incoming || {}).sort(),
mergedKeys: Object.keys(merged || {}).sort(),
});
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]);
res.json({ ok: true });
});
/* ─── Require critical env vars ─────────────────────────────── */
if (!process.env.CORS_ALLOWED_ORIGINS) {
console.error('FATAL CORS_ALLOWED_ORIGINS is not set');
@ -684,10 +867,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 +905,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 +1050,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 +1071,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 +1107,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 +2800,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 +2895,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 +2985,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 +3019,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 +3051,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 +3321,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 +3352,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 +3603,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 +3624,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 +3989,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 +4052,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 +4824,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 +4845,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 +4889,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,37 @@
# AptivaAI Test Components (Curated, Active Only)
> Source of truth for what we **do test**. Keep this file tight and current.
> Add a component/feature here **before** adding tests.
## ✅ Active Components
### A. Auth & Profile (server1)
- **Feature A1**: SignUp → SignIn (cookie) → User Profile (JIT-PII: no `id`)
- Test: `backend/tests/auth_signup_signin.mjs`
### B. Support & Messaging (server3)
- **Feature B1**: `/api/support` auth, dedupe, rate limits, negatives (invalid category, short message)
- Test: `backend/tests/support_limits.mjs`
### C. Subscription & Paywall (server3)
- **Feature C1**: `/api/premium/subscription/status` returns `{ is_premium:false, is_pro_premium:false }` for new user; unauth → 401
- Test: `backend/tests/subscription_status.mjs`
---
## 🟨 Pending Confirmation (do **not** test until moved above)
- Premium Onboarding draft save/load (server3)
- Career data & caching (server2) salary & O*NET warm-cache
- Loan Repayment & ROI (free)
- Milestones & AI suggestions (server3)
- Financial Projection service (frontend utils server3)
- College Mode (premium)
- Reminders & Twilio (server3)
- AI chat risk analysis consumption (server3)
- Nginx/Secrets/CSP checks
- DB connectivity (MySQL SSL) & SQLite reads
- Caching & file safety
- Logging (rid present, no tokens/PII)
> Move items up only after you confirm theyre current and in scope.

View File

@ -0,0 +1,126 @@
// Run:
// node backend/tests/auth_signup_signin.mjs
// BASE=https://staging.aptivaai.com ALLOW_NON_DEV=1 node backend/tests/auth_signup_signin.mjs
//
// Behavior:
// - Creates a brand-new user each run (unique email/username)
// - Cookie-based auth only (captures Set-Cookie from register/signin)
// - Verifies /api/signin returns { message }, /api/user-profile returns 200 JSON w/ NO id leakage
// - Verifies /api/user-profile?fields=… respects allowlist
// - Verifies /api/logout clears cookie and subsequent /api/user-profile is unauthorized
// - Defaults to dev; requires ALLOW_NON_DEV=1 to run on non-dev BASE
import assert from 'node:assert/strict';
const BASE = process.env.BASE || 'https://dev1.aptivaai.com';
if (BASE !== 'https://dev1.aptivaai.com' && process.env.ALLOW_NON_DEV !== '1') {
console.error(`Refusing to run against non-dev BASE='${BASE}'. Set ALLOW_NON_DEV=1 to override.`);
process.exit(2);
}
const j = (o) => JSON.stringify(o);
const rand = () => Math.random().toString(36).slice(2, 10);
const email = `jcoakley@aptivaai.com`;
const username = `qa_${rand()}`;
const password = `Aa1!${rand()}Z`;
let cookie = ''; // session cookie (auth)
function captureSetCookie(headers) {
// In ESM fetch, headers.get('set-cookie') returns the first Set-Cookie (enough for session)
const sc = headers.get('set-cookie');
if (sc) cookie = sc.split(';')[0];
}
async function req(path, { method = 'GET', headers = {}, body } = {}) {
const h = {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
...headers,
};
const res = await fetch(`${BASE}${path}`, {
method,
headers: h,
body: body ? j(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { res, text, json };
}
(async () => {
// 1) Register (201)
{
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, got ${res.status}`);
captureSetCookie(res.headers);
assert.ok(cookie, 'session cookie must be set after register');
}
// 2) Sign in (200) — cookie refreshed, { message } in body
{
const { res, json } = await req('/api/signin', {
method: 'POST',
body: { username, password },
});
assert.equal(res.status, 200, `signin should 200, got ${res.status}`);
assert.ok(json && typeof json.message === 'string', 'signin returns { message }');
captureSetCookie(res.headers);
assert.ok(cookie, 'session cookie must be present after signin');
}
// 3) Profile (200, JSON, no id leakage)
{
const { res, json, text } = await req('/api/user-profile');
assert.equal(res.status, 200, `profile fetch should 200, got ${res.status}, body=${text.slice(0,120)}`);
assert.ok(json && typeof json === 'object', 'profile returns JSON object');
if ('id' in json || 'user_id' in json) throw new Error('profile must NOT include id/user_id');
}
// 4) Field-filtered profile (allowlist)
{
const fields = 'firstname,lastname,career_situation';
const { res, json, text } = await req(`/api/user-profile?fields=${encodeURIComponent(fields)}`);
assert.equal(res.status, 200, `filtered profile should 200, got ${res.status}, body=${text.slice(0,120)}`);
const keys = Object.keys(json || {});
for (const k of keys) {
if (!['firstname','lastname','career_situation','sms_opt_in','phone_e164','email'].includes(k)) {
throw new Error(`unexpected field '${k}' in filtered profile`);
}
}
}
// 5) Username existence
{
const { res, json } = await req(`/api/check-username/${encodeURIComponent(username)}`);
assert.equal(res.status, 200, 'check-username should 200');
assert.equal(json?.exists, true, 'new username should exist');
}
// 6) Logout then profile blocked
{
const out = await req('/api/logout', { method: 'POST' });
assert.equal(out.res.status, 200, `logout should 200, got ${out.res.status}`);
cookie = ''; // simulate cleared cookie
const { res } = await req('/api/user-profile');
if (res.status === 200) throw new Error('profile should NOT be accessible after logout');
}
console.log('✓ AUTH regression suite passed');
})().catch((e) => {
console.error('✖ AUTH regression failed:', e?.message || e);
process.exit(1);
});

View File

@ -0,0 +1,24 @@
await component('Auth & Profile', [
() => feature('SignUp → SignIn → Profile (cookie, no id leakage)',
'backend/tests/auth_signup_signin.mjs'),
]);
// ─────────────────────────────────────────────────────────────
// Component: Support & Messaging (server3)
// Feature: /api/support auth, dedupe, rate limits, negatives
// ─────────────────────────────────────────────────────────────
await component('Support & Messaging', [
() => feature('Support: auth/dup/rate-limit/negatives',
'backend/tests/support_limits.mjs', { BURST: process.env.BURST || '20' }),
]);
// ─────────────────────────────────────────────────────────────
// Component: Subscription & Paywall (server3)
// Feature: status flags (no PII, default false/false)
// ─────────────────────────────────────────────────────────────
await component('Subscription & Paywall', [
() => feature('Subscription status flags', 'backend/tests/subscription_status.mjs'),
]);

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

@ -0,0 +1,174 @@
// Run:
// node backend/tests/support_limits.mjs
// BASE=https://staging.aptivaai.com ALLOW_NON_DEV=1 node backend/tests/support_limits.mjs
//
// Behavior:
// - Cookie-based auth only (new user each run, using jcoakley@aptivaai.com by default)
// - Unauth /api/support → 401
// - First + immediate duplicate → each may be 2xx/202 (ok), 429 (rate-limited), or 503 (no SENDGRID)
// Dedupe happens before SENDGRID check, so if first is 503, duplicate often 202 (unless rate-limited).
// We accept {200,201,202,204,429,503} for each, and require that at least one is not 429.
//
import assert from 'node:assert/strict';
const BASE = process.env.BASE || 'https://dev1.aptivaai.com';
if (BASE !== 'https://dev1.aptivaai.com' && process.env.ALLOW_NON_DEV !== '1') {
console.error(`Refusing to run against non-dev BASE='${BASE}'. Set ALLOW_NON_DEV=1 to override.`);
process.exit(2);
}
const j = (o) => JSON.stringify(o);
const rand = () => Math.random().toString(36).slice(2, 10);
const email = process.env.QA_EMAIL || 'jcoakley@aptivaai.com';
const username = `qa_${rand()}`;
const password = `Aa1!${rand()}Z`;
let cookie = ''; // session cookie (auth)
function captureSetCookie(headers) {
const sc = headers.get('set-cookie');
if (sc) cookie = sc.split(';')[0];
}
async function req(path, { method = 'GET', headers = {}, body } = {}) {
const h = {
'Content-Type': 'application/json',
...(cookie ? { Cookie: cookie } : {}),
...headers,
};
const res = await fetch(`${BASE}${path}`, {
method,
headers: h,
body: body ? j(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { res, text, json };
}
async function reqNoAuth(path, { method = 'GET', headers = {}, body } = {}) {
const h = { 'Content-Type': 'application/json', ...headers };
const res = await fetch(`${BASE}${path}`, {
method, headers: h, body: body ? j(body) : undefined,
});
const text = await res.text();
let json = null;
try { json = JSON.parse(text); } catch {}
return { res, text, json };
}
(async () => {
// 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, got ${res.status}`);
captureSetCookie(res.headers);
assert.ok(cookie, 'session cookie must be set after register');
}
// Sign in (refresh cookie)
{
const { res, json } = await req('/api/signin', {
method: 'POST',
body: { username, password },
});
assert.equal(res.status, 200, `signin should 200, got ${res.status}`);
assert.ok(json && typeof json.message === 'string', 'signin returns { message }');
captureSetCookie(res.headers);
}
// Unauthenticated request should 401
{
const { res } = await reqNoAuth('/api/support', {
method: 'POST',
body: { subject: 'unauth test', category: 'general', message: 'unauth test message' },
});
assert.equal(res.status, 401, `unauth /api/support should 401, got ${res.status}`);
}
// First + duplicate: allow {200,201,202,204,429,503}; require at least one NOT 429
const dedupePayload = {
subject: `QA support ${Date.now()}`,
category: 'technical',
message: `QA support test ${Date.now()}`
};
const first = await req('/api/support', { method: 'POST', body: dedupePayload });
const dup = await req('/api/support', { method: 'POST', body: dedupePayload });
const valid = (s) => [200,201,202,204,429,503].includes(s);
if (!valid(first.res.status)) {
throw new Error(`/api/support first unexpected ${first.res.status}, body=${first.text.slice(0,120)}`);
}
if (!valid(dup.res.status)) {
throw new Error(`/api/support duplicate unexpected ${dup.res.status}, body=${dup.text.slice(0,120)}`);
}
const anyNot429 = [first.res.status, dup.res.status].some((s) => s !== 429);
if (!anyNot429) {
throw new Error(`/api/support first+duplicate were both 429 (statuses: ${first.res.status}, ${dup.res.status})`);
}
console.log('✓ SUPPORT: unauth→401, first+dup→(allowed with ≥1 non-429) — starting burst…');
// Burst to trigger rate limit (unique messages to avoid dedupe masking)
const N = Number(process.env.BURST || 20);
const tasks = Array.from({ length: N }, (_, i) =>
req('/api/support', {
method: 'POST',
body: {
subject: `burst ${i}`,
category: 'technical',
message: `burst ${i} ${Date.now()} ${rand()}`
},
})
);
const results = await Promise.all(tasks);
const codes = results.map(r => r.res.status);
const allowed = new Set([200,201,202,204,429,503]);
const rlCount = codes.filter(c => c === 429).length;
if (!codes.every(c => allowed.has(c))) {
throw new Error(`unexpected status in burst: ${codes.join(',')}`);
}
if (rlCount < 1) {
throw new Error(`expected at least one 429 during burst; got codes=${codes.join(',')}`);
}
// Negative cases: invalid category and too-short message
{
const badCat = await req('/api/support', {
method: 'POST',
body: { subject: 'x', category: 'nope', message: 'valid message content' }
});
if (badCat.res.status !== 400 && badCat.res.status !== 429) {
// Allow 429 if limiter tripped; otherwise require 400 for invalid category
throw new Error(`/api/support invalid category expected 400 or 429, got ${badCat.res.status}`);
}
}
{
const tooShort = await req('/api/support', {
method: 'POST',
body: { subject: 'x', category: 'general', message: 'hi' } // < 5 chars
});
if (tooShort.res.status !== 400 && tooShort.res.status !== 429) {
throw new Error(`/api/support short message expected 400 or 429, got ${tooShort.res.status}`);
}
}
console.log('✓ SUPPORT: unauth→401, first+dup→(allowed with ≥1 non-429), burst→(allowed 2xx/429/503 with ≥1 429), negatives→400/429');
})().catch((e) => {
console.error('✖ SUPPORT regression failed:', e?.message || e);
process.exit(1);
});

View File

@ -50,6 +50,9 @@ services:
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY}
EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET}
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID}
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN}
TWILIO_MESSAGING_SERVICE_SID: ${TWILIO_MESSAGING_SERVICE_SID}
SALARY_DB_PATH: /app/salary_info.db
FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER}
volumes:
@ -133,6 +136,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

@ -248,3 +248,7 @@ mysqldump \
user_profile_db > full_schema.sql
-- /home/jcoakley/sql/2025-09-11_add_verification_flags.sql
ALTER TABLE user_profile
ADD COLUMN email_verified_at DATETIME NULL AFTER email_lookup,
ADD COLUMN phone_verified_at DATETIME NULL AFTER phone_e164;

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

64
package-lock.json generated
View File

@ -70,6 +70,7 @@
"@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.55.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21",
@ -3328,6 +3329,22 @@
"node": ">=14"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.17",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.17.tgz",
@ -15475,6 +15492,53 @@
"node": ">=4"
}
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/pluralize": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz",

View File

@ -104,6 +104,7 @@
"@babel/parser": "^7.28.0",
"@babel/plugin-proposal-private-property-in-object": "^7.21.11",
"@eslint/js": "^9.32.0",
"@playwright/test": "^1.55.0",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"autoprefixer": "^10.4.21",

View File

@ -1,6 +0,0 @@
const { defineConfig } = require('@playwright/test');
module.exports = defineConfig({
testDir: 'tests',
projects:[ {name:'chromium', use:{browserName:'chromium'}} ],
timeout: 30000,
});

27
playwright.config.mjs Normal file
View File

@ -0,0 +1,27 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
// Limit Playwright to E2E specs only
testDir: '/home/jcoakley/aptiva-dev1-app/tests/e2e',
testMatch: /.*\.spec\.(?:mjs|js|ts)$/,
use: {
baseURL: process.env.PW_BASE_URL || 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
retries: 0,
// Add 'blob' so Playwright persists failures for --last-failed
reporter: [['list'], ['html', { open: 'never' }], ['blob']],
// Make Edge the default (you asked earlier)
projects: [
{
name: 'edge',
use: { ...devices['Desktop Edge'] },
},
],
});

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';
@ -44,6 +45,8 @@ import ResetPassword from './components/ResetPassword.js';
import { clearToken } from './auth/authMemory.js';
import api from './auth/apiClient.js';
import * as safeLocal from './utils/safeLocal.js';
import VerificationGate from './components/VerificationGate.js';
import Verify from './components/Verify.js';
@ -68,12 +71,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 +106,48 @@ 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 });
} else {
// user chose to leave: clear client pointer and server draft immediately
try {
safeLocal.clearMany(['premiumOnboardingPointer', 'premiumOnboardingState']);
} catch {}
(async () => {
try { await api.delete('/api/premium/onboarding/draft'); } catch {}
})();
}
// 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 +200,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 +225,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 +260,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 +273,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) {
@ -264,6 +296,7 @@ const confirmLogout = async () => {
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
'premiumOnboardingPointer',
'financialProfile',
'selectedScenario',
]);
@ -296,8 +329,6 @@ const cancelLogout = () => {
);
}
// =====================
// Main Render / Layout
// =====================
@ -558,7 +589,6 @@ const cancelLogout = () => {
<SupportModal
open={supportOpen}
onClose={() => setSupportOpen(false)}
userEmail={userEmail}
/>
{/* LOGOUT BUTTON */}
@ -627,81 +657,33 @@ const cancelLogout = () => {
<Route path="/paywall" element={<Paywall />} />
<Route path="/verify" element={<Verify />} />
{/* Authenticated routes */}
{isAuthenticated && (
<>
<Route path="/signin-landing" element={<SignInLanding user={user} />} />
<Route path="/interest-inventory" element={<InterestInventory />} />
<Route path="/profile" element={<UserProfile />} />
<Route path="/planning" element={<PlanningLanding />} />
<Route path="/career-explorer" element={<CareerExplorer />} />
<Route path="/loan-repayment" element={<LoanRepaymentPage />} />
<Route path="/educational-programs" element={<EducationalProgramsPage />} />
<Route path="/preparing" element={<PreparingLanding />} />
<Route path="/signin-landing" element={<VerificationGate><SignInLanding user={user} /></VerificationGate>} />
<Route path="/interest-inventory" element={<VerificationGate><InterestInventory /></VerificationGate>} />
<Route path="/profile" element={<VerificationGate><UserProfile /></VerificationGate>} />
<Route path="/planning" element={<VerificationGate><PlanningLanding /></VerificationGate>} />
<Route path="/career-explorer" element={<VerificationGate><CareerExplorer /></VerificationGate>} />
<Route path="/loan-repayment" element={<VerificationGate><LoanRepaymentPage /></VerificationGate>} />
<Route path="/educational-programs" element={<VerificationGate><EducationalProgramsPage /></VerificationGate>} />
<Route path="/preparing" element={<VerificationGate><PreparingLanding /></VerificationGate>} />
<Route path="/billing" element={<BillingResult />} />
{/* Premium-wrapped */}
<Route
path="/enhancing"
element={
<PremiumRoute user={user}>
<EnhancingLanding />
</PremiumRoute>
}
/>
<Route
path="/retirement"
element={
<PremiumRoute user={user}>
<RetirementLanding />
</PremiumRoute>
}
/>
<Route
path="/career-roadmap/:careerId?"
element={
<PremiumRoute user={user}>
<CareerRoadmap />
</PremiumRoute>
}
/>
<Route path="/profile/careers" element={<CareerProfileList />} />
<Route path="/profile/careers/:id/edit" element={<CareerProfileForm />} />
<Route path="/profile/college" element={<CollegeProfileList />} />
<Route path="/profile/college/:careerId/:id?" element={<CollegeProfileForm />} />
<Route
path="/financial-profile"
element={
<PremiumRoute user={user}>
<FinancialProfileForm />
</PremiumRoute>
}
/>
<Route
path="/retirement-planner"
element={
<PremiumRoute user={user}>
<RetirementPlanner />
</PremiumRoute>
}
/>
<Route
path="/premium-onboarding"
element={
<PremiumRoute user={user}>
<OnboardingContainer />
</PremiumRoute>
}
/>
<Route
path="/resume-optimizer"
element={
<PremiumRoute user={user}>
<ResumeRewrite />
</PremiumRoute>
}
/>
<Route path="/enhancing" element={<VerificationGate><PremiumRoute user={user}><EnhancingLanding /></PremiumRoute></VerificationGate>} />
<Route path="/retirement" element={<VerificationGate><PremiumRoute user={user}><RetirementLanding /></PremiumRoute></VerificationGate>} />
<Route path="/career-roadmap/:careerId?" element={<VerificationGate><PremiumRoute user={user}><CareerRoadmap /></PremiumRoute></VerificationGate>} />
<Route path="/profile/careers" element={<VerificationGate><CareerProfileList /></VerificationGate>} />
<Route path="/profile/careers/:id/edit" element={<VerificationGate><CareerProfileForm /></VerificationGate>} />
<Route path="/profile/college" element={<VerificationGate><CollegeProfileList /></VerificationGate>} />
<Route path="/profile/college/:careerId/:id?" element={<VerificationGate><CollegeProfileForm /></VerificationGate>} />
<Route path="/financial-profile" element={<VerificationGate><PremiumRoute user={user}><FinancialProfileForm /></PremiumRoute></VerificationGate>} />
<Route path="/retirement-planner" element={<VerificationGate><PremiumRoute user={user}><RetirementPlanner /></PremiumRoute></VerificationGate>} />
<Route path="/premium-onboarding" element={<VerificationGate><PremiumRoute user={user}><OnboardingContainer /></PremiumRoute></VerificationGate>} />
<Route path="/resume-optimizer" element={<VerificationGate><PremiumRoute user={user}><ResumeRewrite /></PremiumRoute></VerificationGate>} />
</>
)}

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

@ -1,7 +1,9 @@
import React, { useEffect, useState } from 'react';
import React, { useEffect, useState, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import apiFetch from '../auth/apiFetch.js';
import moment from 'moment/moment.js';
import Modal from './ui/modal.js';
import FinancialAidWizard from './FinancialAidWizard.js';
const authFetch = apiFetch; // keep local name, new implementation
/** -----------------------------------------------------------
@ -48,21 +50,26 @@ const toMySqlDate = iso => {
return iso.replace('T', ' ').slice(0, 19);
};
export default function CollegeProfileForm() {
const { careerId, id } = useParams(); // id optional
const nav = useNavigate();
const [cipRows, setCipRows] = useState([]);
const [schoolSug, setSchoolSug] = useState([]);
const [progSug, setProgSug] = useState([]);
const [types, setTypes] = useState([]);
const [ipeds, setIpeds] = useState([]);
const [schoolValid, setSchoolValid] = useState(true);
const [programValid, setProgramValid] = useState(true);
const [autoGradDate, setAutoGradDate] = useState('');
const [graduationTouched, setGraduationTouched] = useState(false);
const [programLengthTouched, setProgramLengthTouched] = useState(false);
const schoolData = cipRows;
const [selectedUnitId, setSelectedUnitId] = useState(null);
const [showAidWizard, setShowAidWizard] = useState(false);
const schoolPrevRef = useRef('');
const programPrevRef = useRef('');
const lastSchoolText = useRef('');
const canonicalSchoolName = useRef(''); // the exact, server-known school name for selectedUnitId
const [form, setForm] = useState({
career_profile_id : careerId,
@ -81,6 +88,10 @@ export default function CollegeProfileForm() {
const [autoTuition, setAutoTuition] = useState(0);
const firstOfNextMonth = (d) => {
return moment(d).startOf('month').add(1, 'month').format('YYYY-MM-DD');
};
// ---------- handlers (inside component) ----------
const handleFieldChange = (e) => {
const { name, value, type, checked } = e.target;
@ -98,6 +109,11 @@ const handleFieldChange = (e) => {
) {
draft[name] = value === '' ? '' : parseFloat(value);
if (name === 'program_length') setProgramLengthTouched(true);
if (name === 'program_type') {
draft[name] = value;
draft.credit_hours_required = '';
setProgramLengthTouched(false);
}
} else {
draft[name] = value;
}
@ -105,59 +121,100 @@ const handleFieldChange = (e) => {
});
};
const onSchoolInput = (e) => {
handleFieldChange(e);
const v = e.target.value.toLowerCase();
const suggestions = cipRows
.filter((r) => r.INSTNM.toLowerCase().includes(v))
.map((r) => r.INSTNM);
setSchoolSug([...new Set(suggestions)].slice(0, 10));
};
const onProgramInput = (e) => {
handleFieldChange(e);
if (!form.selected_school) return;
const v = e.target.value.toLowerCase();
const sug = cipRows
.filter(
(r) =>
r.INSTNM.toLowerCase() === form.selected_school.toLowerCase() &&
r.CIPDESC.toLowerCase().includes(v)
)
.map((r) => r.CIPDESC);
setProgSug([...new Set(sug)].slice(0, 10));
};
// Prefill school suggestions when form loads or school changes
useEffect(() => {
const v = (form.selected_school || '').toLowerCase().trim();
if (!v || !cipRows.length) {
setSchoolSug([]);
return;
}
const suggestions = cipRows
.filter(r => (r.INSTNM || '').toLowerCase().includes(v))
.map(r => r.INSTNM);
setSchoolSug([...new Set(suggestions)].slice(0, 10));
}, [form.selected_school, cipRows]);
// Prefill program suggestions when form loads or program/school changes
useEffect(() => {
const sch = (form.selected_school || '').toLowerCase().trim();
const q = (form.selected_program || '').toLowerCase().trim();
if (!sch || !q || !cipRows.length) {
const onSchoolInput = async (e) => {
handleFieldChange(e);
const value = e.target.value || '';
// If school text changed at all, clear program suggestions and program/type selections
if (value !== lastSchoolText.current) {
setProgSug([]);
return;
setTypes([]);
programPrevRef.current = '';
setForm(prev => ({
...prev,
selected_program: value ? prev.selected_program : '', // clear if user erased school
program_type: ''
}));
setSelectedUnitId(null);
canonicalSchoolName.current = '';
setSchoolValid(value.trim() === ''); // empty = neutral, any text = invalid until validated
lastSchoolText.current = value;
}
const sug = cipRows
.filter(r =>
(r.INSTNM || '').toLowerCase() === sch &&
(r.CIPDESC || '').toLowerCase().includes(q)
)
.map(r => r.CIPDESC);
setProgSug([...new Set(sug)].slice(0, 10));
}, [form.selected_school, form.selected_program, cipRows]);
if (!value.trim()) { setSchoolSug([]); schoolPrevRef.current = ''; return; }
const it = e?.nativeEvent?.inputType; // 'insertReplacementText' on datalist pick
const rep = it === 'insertReplacementText';
const big = Math.abs(value.length - (schoolPrevRef.current || '').length) > 1;
try {
const resp = await authFetch(`/api/schools/suggest?query=${encodeURIComponent(value)}&limit=10`);
const arr = resp.ok ? await resp.json() : [];
const opts = Array.isArray(arr) ? arr : [];
setSchoolSug(opts); // [{ name, unitId }]
const exact = opts.find(o => (o.name || '').toLowerCase() === value.toLowerCase());
if (exact && (rep || big)) {
setSelectedUnitId(exact.unitId ?? null);
setForm(prev => ({
...prev,
selected_school : exact.name,
selected_program: '',
program_type : ''
}));
setProgSug([]); setTypes([]);
}
} catch { setSchoolSug([]); }
schoolPrevRef.current = value;
};
const onProgramInput = async (e) => {
handleFieldChange(e);
const school = (form.selected_school || '').trim();
const value = e.target.value || '';
if (!school || !value) { setProgSug([]); programPrevRef.current = value; return; }
const it = e?.nativeEvent?.inputType; // 'insertReplacementText' when choosing from datalist
const rep = it === 'insertReplacementText';
const big = Math.abs(value.length - (programPrevRef.current || '').length) > 1;
try {
const resp = await authFetch(`/api/programs/suggest?school=${encodeURIComponent(school)}&query=${encodeURIComponent(value)}&limit=10`);
const arr = resp.ok ? await resp.json() : [];
const opts = Array.isArray(arr) ? arr : []; // [{ program }]
setProgSug(opts);
// Early commit if exact match was selected from the list (prevents double-pick annoyance)
const exact = opts.find(p => (p.program || '').toLowerCase() === value.toLowerCase());
if (exact && (rep || big)) {
setForm(prev => ({ ...prev, selected_program: exact.program }));
setTypes([]); // will refetch types below via effect/blur
}
} catch {
setProgSug([]);
}
programPrevRef.current = value;
};
// Prefill program suggestions once school+program exist (e.g., after API load)
useEffect(() => {
const school = (form.selected_school || '').trim();
const q = (form.selected_program || '').trim();
if (!school || !q) { setProgSug([]); return; }
(async () => {
try {
const resp = await authFetch(`/api/programs/suggest?school=${encodeURIComponent(school)}&query=${encodeURIComponent(q)}&limit=10`);
const arr = resp.ok ? await resp.json() : [];
setProgSug(Array.isArray(arr) ? arr : []);
} catch { setProgSug([]); }
})();
}, [form.selected_school, form.selected_program]);
useEffect(() => {
// Only clear when the change came from the user's typing (onSchoolInput sets lastSchoolText)
const typed = (form.selected_school || '').trim() === (lastSchoolText.current || '').trim();
if (!typed) return; // programmatic changes (initial load, API normalization) → keep UNITID & types
setProgSug([]);
setTypes([]);
setSelectedUnitId(null);
programPrevRef.current = '';
}, [form.selected_school]);
useEffect(() => {
if (id && id !== 'new') {
@ -174,130 +231,204 @@ useEffect(() => {
is_online : !!raw.is_online,
loan_deferral_until_graduation : !!raw.loan_deferral_until_graduation,
};
setForm(normalized);
if (normalized.tuition !== undefined && normalized.tuition !== null) {
setManualTuition(String(normalized.tuition));
}
setForm(normalized);
// Show saved tuition immediately; estimator will overwrite when deps change
if (normalized.tuition != null) {
const n = Number(normalized.tuition);
setAutoTuition(Number.isFinite(n) ? n : 0);
}
if (normalized.unit_id) setSelectedUnitId(normalized.unit_id);
if (normalized.selected_school) canonicalSchoolName.current = normalized.selected_school;
// If profile came with school+program, load types so Degree Type select is populated
if ((normalized.selected_school || '') && (normalized.selected_program || '')) {
try {
const resp = await authFetch(
`/api/programs/types?school=${encodeURIComponent(normalized.selected_school)}&program=${encodeURIComponent(normalized.selected_program)}`
);
const data = resp.ok ? await resp.json() : null;
setTypes(Array.isArray(data?.types) ? data.types : []);
} catch {}
}
}
})();
}
}, [careerId, id]);
// 2) keep manualTuition aligned if form.tuition is updated elsewhere
useEffect(() => {
if (form.tuition !== undefined && form.tuition !== null) {
if (manualTuition.trim() === '') {
setManualTuition(String(form.tuition));
}
}
}, [form.tuition]);
async function handleSave(){
try{
const body = normalisePayload({ ...form, tuition: chosenTuition, career_profile_id: careerId });
const res = await authFetch('/api/premium/college-profile',{
method:'POST',
headers:{'Content-Type':'application/json'},
body:JSON.stringify(body)
});
if(!res.ok) throw new Error(await res.text());
alert('Saved!');
setForm(p => ({ ...p, tuition: chosenTuition }));
setManualTuition(String(chosenTuition));
nav(-1);
}catch(err){ console.error(err); alert(err.message);}
async function handleSave() {
try {
// Compute chosen tuition exactly like Onboarding (manual override wins; blank => auto)
const chosenTuition =
(manualTuition.trim() === '')
? autoTuition
: (Number.isFinite(parseFloat(manualTuition)) ? parseFloat(manualTuition) : autoTuition);
const schoolText = (form.selected_school || '').trim();
const progText = (form.selected_program || '').trim();
const school = schoolText.toLowerCase();
const prog = progText.toLowerCase();
// ---- SCHOOL validation ----
const exactSchoolLocal = school && schoolSug.find(o => (o.name || '').toLowerCase() === school);
const hasCanonical = !!selectedUnitId &&
canonicalSchoolName.current &&
canonicalSchoolName.current.toLowerCase() === school;
let exactSchool = exactSchoolLocal;
if (school && !exactSchool && !hasCanonical) {
// one-shot server confirm
try {
const resp = await authFetch(`/api/schools/suggest?query=${encodeURIComponent(schoolText)}&limit=50`);
const arr = resp.ok ? await resp.json() : [];
exactSchool = Array.isArray(arr)
? arr.find(o => (o.name || '').toLowerCase() === school)
: null;
if (exactSchool && !selectedUnitId) {
setSelectedUnitId(exactSchool.unitId ?? null);
canonicalSchoolName.current = exactSchool.name || schoolText;
}
} catch {}
}
/* LOAD iPEDS ----------------------------- */
if (school && !exactSchool && !hasCanonical) {
setSchoolValid(false);
alert('Please pick a school from the list.');
return;
}
// ---- PROGRAM validation ----
const progIsEmpty = progText === '';
if (progIsEmpty) {
setProgramValid(false);
alert('Please pick a program from the list.');
return;
}
let exactProgram = prog && progSug.find(p => (p.program || '').toLowerCase() === prog);
// One-shot server confirm if suggestions are empty/stale
if (!exactProgram) {
try {
const resp = await authFetch(
`/api/programs/suggest?school=${encodeURIComponent(schoolText)}&query=${encodeURIComponent(progText)}&limit=50`
);
const arr = resp.ok ? await resp.json() : [];
exactProgram = Array.isArray(arr)
? arr.find(p => (p.program || '').toLowerCase() === prog)
: null;
} catch {}
}
if (!exactProgram) {
setProgramValid(false);
alert('Please pick a program from the list.');
return;
}
// ---- SAVE ----
const body = normalisePayload({
...form,
tuition: chosenTuition,
career_profile_id: careerId,
unit_id: selectedUnitId ?? null
});
const res = await authFetch('/api/premium/college-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (!res.ok) throw new Error(await res.text());
alert('Saved!');
setForm(p => ({ ...p, tuition: chosenTuition }));
setManualTuition(String(chosenTuition));
nav(-1);
} catch (err) {
console.error(err);
alert(err.message);
}
}
useEffect(() => {
const sch = (form.selected_school || '').trim();
const prog = (form.selected_program || '').trim();
if (!sch || !prog) { setTypes([]); return; }
(async () => {
try {
const resp = await authFetch(`/api/programs/types?school=${encodeURIComponent(sch)}&program=${encodeURIComponent(prog)}`);
const data = resp.ok ? await resp.json() : null;
const arr = Array.isArray(data?.types) ? data.types : [];
setTypes(arr);
} catch { setTypes([]); }
})();
}, [form.selected_school, form.selected_program]);
// Resolve UNITID from typed/loaded school name (profile doesn't store unit_id)
useEffect(() => {
const name = (form.selected_school || '').trim();
if (!name || selectedUnitId) return;
let cancelled = false;
(async () => {
try {
// try a wider net so exact always shows up
const resp = await authFetch(`/api/schools/suggest?query=${encodeURIComponent(name)}&limit=50`);
if (!resp.ok) return;
const arr = await resp.json();
const exact = Array.isArray(arr)
? arr.find(o => (o.name || '').toLowerCase() === name.toLowerCase())
: null;
if (!cancelled && exact?.unitId) setSelectedUnitId(exact.unitId);
} catch {}
})();
return () => { cancelled = true; };
}, [form.selected_school, selectedUnitId]);
// Auto-calc Yearly Tuition via server (parity with Onboarding)
useEffect(() => {
fetch('/ic2023_ay.csv', { credentials: 'omit' })
.then(r => r.text())
.then(text => {
const rows = text.split('\n').map(l => l.split(','));
const headers = rows[0];
const parsed = rows.slice(1).map(r =>
Object.fromEntries(r.map((v,i)=>[headers[i], v]))
);
setIpeds(parsed); // you already declared setIpeds
})
.catch(err => console.error('iPEDS load failed', err));
}, []);
useEffect(() => { fetch('/cip_institution_mapping_new.json', { credentials: 'omit' })
.then(r=>r.text()).then(t => setCipRows(
t.split('\n').map(l=>{try{return JSON.parse(l)}catch{ return null }})
.filter(Boolean)
));
fetch('/ic2023_ay.csv')
.then(r=>r.text()).then(csv=>{/* identical to CollegeOnboarding */});
},[]);
useEffect(()=>{
if(!form.selected_school || !form.selected_program) { setTypes([]); return; }
const t = cipRows.filter(r =>
r.INSTNM.toLowerCase()===form.selected_school.toLowerCase() &&
r.CIPDESC===form.selected_program)
.map(r=>r.CREDDESC);
setTypes([...new Set(t)]);
},[form.selected_school, form.selected_program, cipRows]);
useEffect(() => {
if (!ipeds.length) return;
if (!form.selected_school ||
!form.program_type ||
!form.credit_hours_per_year) return;
/* 1 ─ locate UNITID */
const sch = cipRows.find(
r => r.INSTNM.toLowerCase() === form.selected_school.toLowerCase()
);
if (!sch) return;
const unitId = sch.UNITID;
const row = ipeds.find(r => r.UNITID === unitId);
if (!row) return;
/* 2 ─ decide instate / district buckets */
const grad = [
"Master's Degree","Doctoral Degree",
"Graduate/Professional Certificate","First Professional Degree"
].includes(form.program_type);
const pick = (codeInDist, codeInState, codeOut) => {
if (form.is_in_district) return row[codeInDist];
else if (form.is_in_state) return row[codeInState];
else return row[codeOut];
};
const partTime = grad
? pick('HRCHG5','HRCHG6','HRCHG7')
: pick('HRCHG1','HRCHG2','HRCHG3');
const fullTime = grad
? pick('TUITION5','TUITION6','TUITION7')
: pick('TUITION1','TUITION2','TUITION3');
const chpy = parseFloat(form.credit_hours_per_year) || 0;
const est = chpy && chpy < 24
? parseFloat(partTime || 0) * chpy
: parseFloat(fullTime || 0);
setAutoTuition(Math.round(est));
(async () => {
const chpy = Number(form.credit_hours_per_year);
const hasCanon = canonicalSchoolName.current &&
canonicalSchoolName.current.toLowerCase() === (form.selected_school || '').trim().toLowerCase();
if (!selectedUnitId || !hasCanon ||
!form.program_type ||
!Number.isFinite(chpy) ||
chpy <= 0) {
// keep previous autoTuition if user has manual override; otherwise show 0
if (manualTuition.trim() === '') setAutoTuition(0);
return;
}
try {
const qs = new URLSearchParams({
unitId: String(selectedUnitId),
programType: form.program_type,
inState: (form.is_in_state ? 1 : 0).toString(),
inDistrict: (form.is_in_district ? 1 : 0).toString(),
creditHoursPerYear: String(chpy),
}).toString();
const resp = await authFetch(`/api/tuition/estimate?${qs}`);
const data = resp.ok ? await resp.json() : {};
const est = Number.isFinite(data?.estimate) ? data.estimate : 0;
if (manualTuition.trim() === '') setAutoTuition(est); // don't clobber manual override
} catch {
if (manualTuition.trim() === '') setAutoTuition(0);
}
})();
}, [
ipeds,
cipRows,
selectedUnitId,
form.selected_school,
form.program_type,
form.credit_hours_per_year,
form.is_in_state,
form.is_in_district
form.is_in_district,
manualTuition // include so clearing manual → auto resumes immediately
]);
const handleManualTuitionChange = e => setManualTuition(e.target.value);
const chosenTuition = (() => {
if (manualTuition.trim() === '') return autoTuition;
const n = parseFloat(manualTuition);
return Number.isFinite(n) ? n : autoTuition;
})();
/*
Autocalculate PROGRAM LENGTH when the user hasnt typed in
@ -306,7 +437,7 @@ const chosenTuition = (() => {
useEffect(() => {
if (programLengthTouched) return; // user override
// if a program_length already exists (e.g., from API), don't overwrite it
if (form.program_length !== '' && form.program_length != null) return; // user override
// user override
const chpy = parseFloat(form.credit_hours_per_year);
if (!chpy || chpy <= 0) return;
@ -341,32 +472,40 @@ const chpy = parseFloat(form.credit_hours_per_year);
programLengthTouched
]);
useEffect(() => {
useEffect(() => {
if (graduationTouched) return;
const years = parseFloat(form.program_length);
if (!years || years <= 0) return;
const start = form.enrollment_date
? moment(form.enrollment_date)
: moment();
// Mirror Onboardings start date selection
let start = null;
if (form.college_enrollment_status === 'prospective_student') {
if (!form.enrollment_date) return; // need users chosen start
start = moment(form.enrollment_date);
} else if (form.college_enrollment_status === 'currently_enrolled') {
start = moment().startOf('month').add(1, 'month'); // first of next month
} else {
// not in-school flows → do nothing
return;
}
const monthsToAdd = Math.round(years * 12);
const est = moment(start).add(monthsToAdd, 'months');
const iso = firstOfNextMonth(est);
const iso = start.add(years, 'years')
.startOf('month')
.format('YYYY-MM-DD');
setAutoGradDate(iso);
setForm(prev => ({ ...prev, expected_graduation: iso }));
}, [
form.program_length,
form.credit_hours_required,
form.credit_hours_per_year,
form.hours_completed,
form.credit_hours_per_year,
form.enrollment_date,
graduationTouched
]);
const handleManualTuitionChange = e => setManualTuition(e.target.value);
return (
<div className="max-w-md mx-auto p-6 space-y-4">
@ -401,22 +540,31 @@ return (
name="selected_school"
value={form.selected_school}
onChange={onSchoolInput}
onBlur={() => {
const ok = cipRows.some(
r => r.INSTNM.toLowerCase() === form.selected_school.toLowerCase()
);
setSchoolValid(ok);
if (!ok) alert('Please pick a school from the list.');
onBlur={() => {
const trimmed = (form.selected_school || '').trim();
const exact = schoolSug.find(o => (o.name || '').toLowerCase() === trimmed.toLowerCase());
// Auto-commit exact typed value so downstream lookups work
if (exact) {
if (form.selected_school !== exact.name) {
setForm(prev => ({ ...prev, selected_school: exact.name }));
}
if (!selectedUnitId) setSelectedUnitId(exact.unitId ?? null);
canonicalSchoolName.current = exact.name;
setSchoolValid(true);
return;
}
// Valid if empty (still choosing) OR exact chosen
// Empty = neutral; any other text is invalid until committed from list
setSchoolValid(trimmed === '');
}}
list="school-suggestions"
className={`w-full border rounded p-2 ${
(form.selected_school || '').trim() !== '' && !schoolValid ? 'border-red-500' : ''}`}
placeholder="Start typing and choose…"
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
required
/>
<datalist id="school-suggestions">
{schoolSug.map((s,i)=>(
<option key={i} value={s} />
))}
{schoolSug.map((s,i)=>(<option key={`${s.unitId ?? i}:${s.name}`} value={s.name} />))}
</datalist>
</div>
@ -429,33 +577,40 @@ return (
name="selected_program"
value={form.selected_program}
onChange={onProgramInput}
onBlur={() => {
const ok =
form.selected_school && // need a school first
cipRows.some(
r =>
r.INSTNM.toLowerCase() === form.selected_school.toLowerCase() &&
r.CIPDESC.toLowerCase() === form.selected_program.toLowerCase()
);
setProgramValid(ok);
if (!ok) alert('Please pick a program from the list.');
}}
list="program-suggestions"
onBlur={() => {
const prog = (form.selected_program || '').trim().toLowerCase();
const exact = progSug.find(p => (p.program || '').toLowerCase() === prog);
// If user typed an exact program, ensure canonical casing is committed
if (exact && form.selected_program !== exact.program) {
setForm(prev => ({ ...prev, selected_program: exact.program }));
}
setProgramValid(prog === '' || !!exact);
}}
list="program-suggestions"
placeholder="Start typing and choose…"
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
className={`w-full border rounded p-2 ${
(form.selected_program || '').trim() !== '' && !programValid ? 'border-red-500' : '' }`}
required
/>
<datalist id="program-suggestions">
{progSug.map((p,i)=>(
<option key={i} value={p} />
))}
</datalist>
<datalist id="program-suggestions">
{progSug.map((p,i)=>(
<option key={`${i}:${p.program}`} value={p.program} />
))}
</datalist>
</div>
{/* 4 │ Programtype */}
<div className="space-y-1">
<label className="block font-medium">Degree Type *</label>
<select
{(() => {
const list =
types && types.length
? (types.includes(form.program_type)
? types
: [form.program_type, ...types.filter(t => t !== form.program_type)].filter(Boolean))
: (form.program_type ? [form.program_type] : []);
return (
<select
name="program_type"
value={form.program_type}
onChange={handleFieldChange}
@ -463,8 +618,9 @@ return (
required
>
<option value="">Select Program Type</option>
{types.map((t,i)=><option key={i} value={t}>{t}</option>)}
</select>
{list.map((t,i)=><option key={`${t}-${i}`} value={t}>{t}</option>)}
</select>);
})()}
</div>
{/* 5 │ Academic calendar */}
@ -554,14 +710,26 @@ return (
className="w-full border rounded p-2"
/>
</div>
<span className="font-medium">Annual Aid</span>
<input
type="number"
name="annual_financial_aid"
value={form.annual_financial_aid}
onChange={handleFieldChange}
className="mt-1 w-full border rounded p-2"
/>
<div className="space-y-1">
<label className="block font-medium">(Estimated) Annual Financial Aid</label>
<div className="flex space-x-2">
<input
type="number"
name="annual_financial_aid"
value={form.annual_financial_aid}
onChange={handleFieldChange}
placeholder="e.g. 2000"
className="w-full border rounded p-2"
/>
<button
type="button"
onClick={() => setShowAidWizard(true)}
className="bg-blue-600 text-center px-3 py-2 rounded text-white"
>
Need Help?
</button>
</div>
</div>
{/* 8 │ Existing debt */}
@ -661,15 +829,26 @@ return (
>
 Back
</button>
<button
onClick={handleSave}
disabled={!schoolValid || !programValid}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
<button
onClick={handleSave}
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
>
Save
</button>
</div>
{showAidWizard && (
<Modal onClose={() => setShowAidWizard(false)}>
<FinancialAidWizard
onAidEstimated={(estimate) => {
setForm(prev => ({ ...prev, annual_financial_aid: estimate }));
}}
onClose={() => setShowAidWizard(false)}
/>
</Modal>
)}
</div>
);

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

@ -188,8 +188,9 @@ const handleSelectSchool = async (school) => {
// 1) normalize college fields
const selected_school = school?.INSTNM || '';
const selected_program = (school?.CIPDESC || '').replace(/\.\s*$/, '');
const selected_program = (school?.CIPDESC || '');
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

@ -12,8 +12,8 @@ function CollegeOnboarding({ nextStep, prevStep, data, setData }) {
const schoolPrevRef = useRef('');
const [programSuggestions, setProgramSuggestions] = useState([]);
const [availableProgramTypes, setAvailableProgramTypes] = useState([]);
const [schoolValid, setSchoolValid] = useState(false);
const [programValid, setProgramValid] = useState(false);
const [schoolValid, setSchoolValid] = useState(true);
const [programValid, setProgramValid] = useState(true);
const [enrollmentDate, setEnrollmentDate] = useState(
data.enrollment_date || ''
);
@ -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 */
@ -400,6 +465,26 @@ useEffect(() => {
// final handleSubmit => we store chosen tuition + program_length, then move on
const handleSubmit = () => {
// enforce “picked from list” at submit time (no blur popups)
const schoolText = (selected_school || '').trim();
const programText = (selected_program || '').trim();
const exactSchool = schoolSuggestions.find(o =>
(o.name || '').toLowerCase() === schoolText.toLowerCase()
);
const exactProgram = programSuggestions.find(p =>
(p.program || '').toLowerCase() === programText.toLowerCase()
);
if (schoolText && !selectedUnitId && !exactSchool) {
setSchoolValid(false);
alert('Please pick a school from the list.');
return;
}
if (programText && !exactProgram && availableProgramTypes.length === 0) {
setProgramValid(false);
alert('Please pick a program from the list.');
return;
}
const chosenTuition = manualTuition.trim() === ''
? autoTuition
: parseFloat(manualTuition);
@ -415,6 +500,12 @@ useEffect(() => {
program_length: chosenProgramLength
}));
saveDraft({ collegeData: {
tuition: Number(chosenTuition) || 0,
program_length: Number(chosenProgramLength) || 0,
expected_graduation: expectedGraduation || ''
}}).catch(()=>{});
nextStep();
};
@ -456,8 +547,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 +560,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 +572,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 +584,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>
@ -501,15 +597,19 @@ const ready =
name="selected_school"
value={selected_school}
onChange={handleSchoolChange}
onBlur={() => {
const exact = schoolSuggestions.find(o => (o.name || '').toLowerCase() === (selected_school || '').toLowerCase());
if (exact) handleSchoolSelect(exact); // ensure UNITID is set
const ok = !!exact || !!selected_school;
setSchoolValid(ok);
if (!ok) alert("Please pick a school from the list.");
}}
onBlur={() => {
const trimmed = (selected_school || '').trim();
const exact = schoolSuggestions.find(o =>
(o.name || '').toLowerCase() === trimmed.toLowerCase()
);
// If exact text was typed, auto-commit so UNITID is set (covers nested/double-select cases)
if (exact && !selectedUnitId) handleSchoolSelect(exact);
// Valid while empty (still choosing) or when exact is chosen
setSchoolValid(trimmed === '' || !!exact);
}}
list="school-suggestions"
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
className={`w-full border rounded p-2 ${ (selected_school || '').trim() !== '' && !schoolValid ? 'border-red-500' : ''}`}
placeholder="Start typing and choose…"
/>
<datalist id="school-suggestions">
@ -529,13 +629,15 @@ const ready =
name="selected_program"
value={selected_program}
onChange={handleProgramChange}
onBlur={() => {
const ok = !!programSuggestions.find(p => (p.program || '').toLowerCase() === (selected_program || '').toLowerCase());
setProgramValid(ok);
if (!ok) alert("Please pick a program from the list.");
onBlur={() => {
const trimmed = (selected_program || '').trim();
const exact = programSuggestions.find(p =>
(p.program || '').toLowerCase() === trimmed.toLowerCase()
);
setProgramValid(trimmed === '' || !!exact);
}}
list="program-suggestions"
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
className={`w-full border rounded p-2 ${(selected_program || '').trim() !== '' && !programValid ? 'border-red-500' : ''}`}
placeholder="Start typing and choose…"
/>
<datalist id="program-suggestions">
@ -590,6 +692,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 +706,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 +733,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 +754,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 +786,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 +807,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 +834,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 +850,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 +863,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 +876,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 +889,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 +921,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

@ -0,0 +1,34 @@
// /home/jcoakley/aptiva-dev1-app/src/components/VerificationGate.jsx
import React, { useEffect, useState } from 'react';
import api from '../auth/apiClient.js';
import { useLocation, Navigate } from 'react-router-dom';
export default function VerificationGate({ children }) {
const [ready, setReady] = useState(false);
const [verified, setVerified] = useState(false);
const loc = useLocation();
useEffect(() => {
let cancelled = false;
(async () => {
try {
// Ask user-profile for extra fields; backend safely ignores unknown "fields"
const { data } = await api.get('/api/user-profile?fields=firstname,is_premium,is_pro_premium,email_verified_at,phone_verified_at');
if (cancelled) return;
const v = Boolean(data?.email_verified_at || data?.phone_verified_at);
setVerified(v);
} catch {
setVerified(false);
} finally {
if (!cancelled) setReady(true);
}
})();
return () => { cancelled = true; };
}, [loc.pathname]);
if (!ready) return null; // keep splash minimal
if (!verified && !/^\/verify(?:$|\?)/.test(loc.pathname)) {
return <Navigate to="/verify" replace />;
}
return children;
}

171
src/components/Verify.js Normal file
View File

@ -0,0 +1,171 @@
// /home/jcoakley/aptiva-dev1-app/src/components/Verify.js
import React, { useEffect, useState } from 'react';
import { Button } from './ui/button.js';
import api from '../auth/apiClient.js';
import { useNavigate } from 'react-router-dom';
export default function Verify() {
const navigate = useNavigate();
const [msg, setMsg] = useState('');
const qs = new URLSearchParams(window.location.search);
const [token, setToken] = useState(() => qs.get('t') || '');
const next = qs.get('next') || '/signin-landing';
const [phone, setPhone] = useState('1');
const [code, setCode] = useState('');
const [sendingEmail, setSendingEmail] = useState(false);
const [sendingSms, setSendingSms] = useState(false);
const [confirmingSms, setConfirmingSms] = useState(false);
const [smsConsent, setSmsConsent] = useState(false); // explicit consent for SMS
const sendEmail = async () => {
if (sendingEmail) return;
setSendingEmail(true);
try {
await api.post('/api/auth/verify/email/send', {});
setMsg('Verification email sent.');
} catch {
setMsg('Could not send verification email.');
} finally {
// auto re-enable after 30s to avoid lockout
setTimeout(() => setSendingEmail(false), 30000);
}
};
const confirmEmail = async () => {
try {
await api.post('/api/auth/verify/email/confirm', { token });
setMsg('Email verified. Redirecting…');
// give backend a heartbeat to persist, then navigate
setTimeout(() => navigate(next, { replace: true }), 350);
}
catch { setMsg('Invalid or expired email token.'); }
};
const sendSms = async () => {
if (sendingSms) return;
setSendingSms(true);
try {
await api.post('/api/auth/verify/phone/send', { phone_e164: phone });
setMsg('SMS code sent.');
} catch {
setMsg('Could not send SMS code.');
} finally {
// re-enable after 30s; avoids hammering the endpoint
setTimeout(() => setSendingSms(false), 30000);
}
};
const confirmSms = async () => {
if (confirmingSms) return;
setConfirmingSms(true);
try {
await api.post('/api/auth/verify/phone/confirm', { code });
setMsg('Phone verified. Redirecting…');
setTimeout(() => navigate(next, { replace: true }), 350);
} catch {
setMsg('Invalid or expired code.');
} finally {
setConfirmingSms(false);
}
};
useEffect(() => {
if (token) { confirmEmail(); } // magic-link auto confirm → then redirect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="max-w-lg mx-auto p-6 space-y-6">
<h1 className="text-xl font-semibold">Verify your account</h1>
<p className="text-sm text-gray-600">You must verify before using AptivaAI.</p>
{/* EMAIL CARD */}
<div className="rounded-2xl border bg-white p-4 shadow-sm space-y-3">
<h2 className="text-sm font-medium text-gray-800">Email verification</h2>
<div className="grid grid-cols-[8rem,1fr,6.5rem] items-stretch gap-2">
<Button
className="w-full text-sm"
onClick={sendEmail}
disabled={sendingEmail}
>
{sendingEmail ? 'Sending…' : 'Send email'}
</Button>
<input
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Paste token"
value={token}
onChange={e=>setToken(e.target.value)}
/>
<Button
className="w-full text-sm"
variant="secondary"
onClick={confirmEmail}
>
Confirm
</Button>
</div>
</div>
{/* PHONE CARD */}
<div className="rounded-2xl border bg-white p-4 shadow-sm space-y-3">
<h2 className="text-sm font-medium text-gray-800">Phone verification (optional)</h2>
{/* Explicit SMS consent (required for A2P accuracy) */}
<div className="flex items-start gap-2">
<input
id="sms-consent"
type="checkbox"
className="mt-1 h-4 w-4 rounded border"
checked={smsConsent}
onChange={e => setSmsConsent(e.target.checked)}
/>
<label htmlFor="sms-consent" className="text-xs text-gray-700 leading-5">
By requesting a code, you agree to receive one-time security texts from AptivaAI to verify your account.
Reply STOP to opt out. Msg & data rates may apply.
</label>
</div>
<div className="grid grid-cols-[1fr,7.5rem] items-stretch gap-2">
<input
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
value={phone}
placeholder="+1XXXXXXXXXX"
onChange={e => {
let d = e.target.value.replace(/\D/g,'');
if (!d.startsWith('1')) d = '1' + d;
const v = '+' + d.slice(0,11);
setPhone(v);
}}
/>
<Button
className="px-3 whitespace-nowrap w-full"
onClick={sendSms}
disabled={sendingSms || !smsConsent}
>
{sendingSms ? 'Sending…' : 'Send code'}
</Button>
</div>
<div className="grid grid-cols-[1fr,6.5rem] items-stretch gap-2">
<input
className="w-full rounded-md border px-3 text-sm outline-none focus:ring-2 focus:ring-blue-500"
placeholder="6-digit code"
value={code}
onChange={e=>setCode(e.target.value)}
/>
<Button
className="px-3 whitespace-nowrap w-full"
variant="secondary"
onClick={confirmSms}
disabled={confirmingSms || !code}
>
{confirmingSms ? 'Checking…' : 'Confirm'}
</Button>
</div>
</div>
{!!msg && <div className="text-sm text-gray-800">{msg}</div>}
</div>
);
}

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

View File

@ -0,0 +1,21 @@
{
"status": "failed",
"failedTests": [
"912b0a42e830d5eb471e-760b803445f71997ff15",
"adebddef88bcf3522d03-5564093ce53787bc37f1",
"e2a1f72bade9c08182fe-6f621548b19be1d1c340",
"04d7e1cfdd54807256b0-d6ea376eb6511af71058",
"31db8689401acd273032-cab17a91a741a429f82d",
"1c59337757c0db6c5b5a-c3a2d557647a05580ec2",
"929c2cc6ba4f564b24fc-946b201d1d2ce3bcc831",
"929c2cc6ba4f564b24fc-bb3dcb00b3273979b065",
"d94173b0fe5d7002a306-4787dc08bfe1459dba5b",
"a5366403b9bfbbbe283e-0f6aea13931c9f9dd89f",
"a5366403b9bfbbbe283e-8723b5b1a3f4093effb0",
"c167e95522508c1da576-f44184408ded1c898957",
"37ddad175c38e79b0f15-93462299db1b1756eedc",
"37ddad175c38e79b0f15-50f35c78cf5a1a8b2635",
"ed5b94c6fed68d1ded5e-79dac3b701e35033b644",
"a22878cb937d50857944-f3a10ac1ede1d0305bc5"
]
}

View File

@ -0,0 +1,253 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- navigation [ref=e6]:
- button "Find Your Career" [ref=e8] [cursor=pointer]
- button "Preparing & UpSkilling for Your Career" [ref=e10] [cursor=pointer]
- button "Enhancing Your Career(Premium)" [ref=e12] [cursor=pointer]:
- text: Enhancing Your Career
- generic [ref=e13] [cursor=pointer]: (Premium)
- button "Retirement Planning (beta)(Premium)" [ref=e15] [cursor=pointer]:
- text: Retirement Planning (beta)
- generic [ref=e16] [cursor=pointer]: (Premium)
- button "Profile" [ref=e18] [cursor=pointer]
- generic [ref=e19]:
- button "Upgrade to Premium" [ref=e20] [cursor=pointer]
- button "Support" [ref=e21] [cursor=pointer]
- button "Logout" [ref=e22] [cursor=pointer]
- main [ref=e23]:
- generic [ref=e24]:
- generic [ref=e25]:
- heading "Explore Careers - use these tools to find your best fit" [level=2] [ref=e26]
- generic [ref=e27]:
- generic [ref=e28]:
- text: Search for Career
- generic [ref=e29]: "*"
- combobox "Start typing a career..." [ref=e31]
- paragraph [ref=e32]: Please pick from the dropdown when performing search. Our database is very comprehensive but cant accommodate every job title—choose the closest match to what youre searching for.
- generic [ref=e33]:
- heading "Career Comparison" [level=2] [ref=e34]
- button "Edit priorities" [ref=e35] [cursor=pointer]
- paragraph [ref=e36]: No careers added to comparison.
- generic [ref=e37]:
- combobox [ref=e38]:
- option "All Preparation Levels" [selected]
- option "Little or No Preparation"
- option "Some Preparation Needed"
- option "Medium Preparation Needed"
- option "Considerable Preparation Needed"
- option "Extensive Preparation Needed"
- combobox [ref=e39]:
- option "All Fit Levels" [selected]
- option "Best - Very Strong Match"
- option "Great - Strong Match"
- option "Good - Less Strong Match"
- button "Reload Career Suggestions" [active] [ref=e40] [cursor=pointer]
- generic [ref=e41]:
- generic [ref=e42]: ⚠️
- generic [ref=e43]: = May have limited data for this career path
- generic [ref=e44]:
- button "Amusement & Recreation Attendants ⚠️" [ref=e45] [cursor=pointer]:
- generic [ref=e46] [cursor=pointer]: Amusement & Recreation Attendants
- generic [ref=e47] [cursor=pointer]: ⚠️
- button "Baristas ⚠️" [ref=e48] [cursor=pointer]:
- generic [ref=e49] [cursor=pointer]: Baristas
- generic [ref=e50] [cursor=pointer]: ⚠️
- button "Bus Drivers, School" [ref=e51] [cursor=pointer]:
- generic [ref=e52] [cursor=pointer]: Bus Drivers, School
- button "Childcare Workers" [ref=e53] [cursor=pointer]:
- generic [ref=e54] [cursor=pointer]: Childcare Workers
- button "Coaches & Scouts" [ref=e55] [cursor=pointer]:
- generic [ref=e56] [cursor=pointer]: Coaches & Scouts
- button "Concierges" [ref=e57] [cursor=pointer]:
- generic [ref=e58] [cursor=pointer]: Concierges
- button "Exercise Trainers & Group Fitness Instructors" [ref=e59] [cursor=pointer]:
- generic [ref=e60] [cursor=pointer]: Exercise Trainers & Group Fitness Instructors
- button "Food Servers, Nonrestaurant ⚠️" [ref=e61] [cursor=pointer]:
- generic [ref=e62] [cursor=pointer]: Food Servers, Nonrestaurant
- generic [ref=e63] [cursor=pointer]: ⚠️
- button "Funeral Attendants ⚠️" [ref=e64] [cursor=pointer]:
- generic [ref=e65] [cursor=pointer]: Funeral Attendants
- generic [ref=e66] [cursor=pointer]: ⚠️
- button "Home Health Aides ⚠️" [ref=e67] [cursor=pointer]:
- generic [ref=e68] [cursor=pointer]: Home Health Aides
- generic [ref=e69] [cursor=pointer]: ⚠️
- button "Hosts & Hostesses, Restaurant, Lounge, & Coffee Shop ⚠️" [ref=e70] [cursor=pointer]:
- generic [ref=e71] [cursor=pointer]: Hosts & Hostesses, Restaurant, Lounge, & Coffee Shop
- generic [ref=e72] [cursor=pointer]: ⚠️
- button "Locker Room, Coatroom, & Dressing Room Attendants ⚠️" [ref=e73] [cursor=pointer]:
- generic [ref=e74] [cursor=pointer]: Locker Room, Coatroom, & Dressing Room Attendants
- generic [ref=e75] [cursor=pointer]: ⚠️
- button "Nannies" [ref=e76] [cursor=pointer]:
- generic [ref=e77] [cursor=pointer]: Nannies
- button "Nursing Assistants" [ref=e78] [cursor=pointer]:
- generic [ref=e79] [cursor=pointer]: Nursing Assistants
- button "Occupational Therapy Aides" [ref=e80] [cursor=pointer]:
- generic [ref=e81] [cursor=pointer]: Occupational Therapy Aides
- button "Passenger Attendants ⚠️" [ref=e82] [cursor=pointer]:
- generic [ref=e83] [cursor=pointer]: Passenger Attendants
- generic [ref=e84] [cursor=pointer]: ⚠️
- button "Personal Care Aides ⚠️" [ref=e85] [cursor=pointer]:
- generic [ref=e86] [cursor=pointer]: Personal Care Aides
- generic [ref=e87] [cursor=pointer]: ⚠️
- button "Physical Therapist Aides" [ref=e88] [cursor=pointer]:
- generic [ref=e89] [cursor=pointer]: Physical Therapist Aides
- button "Recreation Workers" [ref=e90] [cursor=pointer]:
- generic [ref=e91] [cursor=pointer]: Recreation Workers
- button "Residential Advisors" [ref=e92] [cursor=pointer]:
- generic [ref=e93] [cursor=pointer]: Residential Advisors
- button "School Bus Monitors ⚠️" [ref=e94] [cursor=pointer]:
- generic [ref=e95] [cursor=pointer]: School Bus Monitors
- generic [ref=e96] [cursor=pointer]: ⚠️
- button "Substitute Teachers, Short-Term ⚠️" [ref=e97] [cursor=pointer]:
- generic [ref=e98] [cursor=pointer]: Substitute Teachers, Short-Term
- generic [ref=e99] [cursor=pointer]: ⚠️
- button "Teaching Assistants, Preschool, Elementary, Middle, & Secondary School ⚠️" [ref=e100] [cursor=pointer]:
- generic [ref=e101] [cursor=pointer]: Teaching Assistants, Preschool, Elementary, Middle, & Secondary School
- generic [ref=e102] [cursor=pointer]: ⚠️
- button "Teaching Assistants, Special Education ⚠️" [ref=e103] [cursor=pointer]:
- generic [ref=e104] [cursor=pointer]: Teaching Assistants, Special Education
- generic [ref=e105] [cursor=pointer]: ⚠️
- button "Tour Guides & Escorts ⚠️" [ref=e106] [cursor=pointer]:
- generic [ref=e107] [cursor=pointer]: Tour Guides & Escorts
- generic [ref=e108] [cursor=pointer]: ⚠️
- button "Ushers, Lobby Attendants, & Ticket Takers ⚠️" [ref=e109] [cursor=pointer]:
- generic [ref=e110] [cursor=pointer]: Ushers, Lobby Attendants, & Ticket Takers
- generic [ref=e111] [cursor=pointer]: ⚠️
- button "Waiters & Waitresses ⚠️" [ref=e112] [cursor=pointer]:
- generic [ref=e113] [cursor=pointer]: Waiters & Waitresses
- generic [ref=e114] [cursor=pointer]: ⚠️
- button "Adapted Physical Education Specialists" [ref=e115] [cursor=pointer]:
- generic [ref=e116] [cursor=pointer]: Adapted Physical Education Specialists
- button "Adult Basic Education, Adult Secondary Education, & English as a Second Language Instructors" [ref=e117] [cursor=pointer]:
- generic [ref=e118] [cursor=pointer]: Adult Basic Education, Adult Secondary Education, & English as a Second Language Instructors
- button "Athletes & Sports Competitors" [ref=e119] [cursor=pointer]:
- generic [ref=e120] [cursor=pointer]: Athletes & Sports Competitors
- button "Baggage Porters & Bellhops ⚠️" [ref=e121] [cursor=pointer]:
- generic [ref=e122] [cursor=pointer]: Baggage Porters & Bellhops
- generic [ref=e123] [cursor=pointer]: ⚠️
- button "Barbers" [ref=e124] [cursor=pointer]:
- generic [ref=e125] [cursor=pointer]: Barbers
- button "Bartenders" [ref=e126] [cursor=pointer]:
- generic [ref=e127] [cursor=pointer]: Bartenders
- button "Bus Drivers, Transit & Intercity" [ref=e128] [cursor=pointer]:
- generic [ref=e129] [cursor=pointer]: Bus Drivers, Transit & Intercity
- button "Career/Technical Education Teachers, Middle School" [ref=e130] [cursor=pointer]:
- generic [ref=e131] [cursor=pointer]: Career/Technical Education Teachers, Middle School
- button "Career/Technical Education Teachers, Secondary School" [ref=e132] [cursor=pointer]:
- generic [ref=e133] [cursor=pointer]: Career/Technical Education Teachers, Secondary School
- button "Clergy" [ref=e134] [cursor=pointer]:
- generic [ref=e135] [cursor=pointer]: Clergy
- button "Cooks, Private Household" [ref=e136] [cursor=pointer]:
- generic [ref=e137] [cursor=pointer]: Cooks, Private Household
- button "Correctional Officers & Jailers" [ref=e138] [cursor=pointer]:
- generic [ref=e139] [cursor=pointer]: Correctional Officers & Jailers
- button "Dietetic Technicians" [ref=e140] [cursor=pointer]:
- generic [ref=e141] [cursor=pointer]: Dietetic Technicians
- button "Dining Room & Cafeteria Attendants & Bartender Helpers ⚠️" [ref=e142] [cursor=pointer]:
- generic [ref=e143] [cursor=pointer]: Dining Room & Cafeteria Attendants & Bartender Helpers
- generic [ref=e144] [cursor=pointer]: ⚠️
- button "Elementary School Teachers" [ref=e145] [cursor=pointer]:
- generic [ref=e146] [cursor=pointer]: Elementary School Teachers
- button "Fast Food & Counter Workers ⚠️" [ref=e147] [cursor=pointer]:
- generic [ref=e148] [cursor=pointer]: Fast Food & Counter Workers
- generic [ref=e149] [cursor=pointer]: ⚠️
- button "Fitness & Wellness Coordinators" [ref=e150] [cursor=pointer]:
- generic [ref=e151] [cursor=pointer]: Fitness & Wellness Coordinators
- button "Flight Attendants" [ref=e152] [cursor=pointer]:
- generic [ref=e153] [cursor=pointer]: Flight Attendants
- button "Hairdressers, Hairstylists, & Cosmetologists" [ref=e154] [cursor=pointer]:
- generic [ref=e155] [cursor=pointer]: Hairdressers, Hairstylists, & Cosmetologists
- button "Hotel, Motel, & Resort Desk Clerks ⚠️" [ref=e156] [cursor=pointer]:
- generic [ref=e157] [cursor=pointer]: Hotel, Motel, & Resort Desk Clerks
- generic [ref=e158] [cursor=pointer]: ⚠️
- button "Kindergarten Teachers" [ref=e159] [cursor=pointer]:
- generic [ref=e160] [cursor=pointer]: Kindergarten Teachers
- button "Licensed Practical & Licensed Vocational Nurses" [ref=e161] [cursor=pointer]:
- generic [ref=e162] [cursor=pointer]: Licensed Practical & Licensed Vocational Nurses
- button "Middle School Teachers" [ref=e163] [cursor=pointer]:
- generic [ref=e164] [cursor=pointer]: Middle School Teachers
- button "Midwives" [ref=e165] [cursor=pointer]:
- generic [ref=e166] [cursor=pointer]: Midwives
- button "Morticians, Undertakers, & Funeral Arrangers" [ref=e167] [cursor=pointer]:
- generic [ref=e168] [cursor=pointer]: Morticians, Undertakers, & Funeral Arrangers
- button "Occupational Therapy Assistants" [ref=e169] [cursor=pointer]:
- generic [ref=e170] [cursor=pointer]: Occupational Therapy Assistants
- button "Orderlies ⚠️" [ref=e171] [cursor=pointer]:
- generic [ref=e172] [cursor=pointer]: Orderlies
- generic [ref=e173] [cursor=pointer]: ⚠️
- button "Physical Therapist Assistants" [ref=e174] [cursor=pointer]:
- generic [ref=e175] [cursor=pointer]: Physical Therapist Assistants
- button "Preschool Teachers" [ref=e176] [cursor=pointer]:
- generic [ref=e177] [cursor=pointer]: Preschool Teachers
- button "Psychiatric Aides" [ref=e178] [cursor=pointer]:
- generic [ref=e179] [cursor=pointer]: Psychiatric Aides
- button "Reservation & Transportation Ticket Agents & Travel Clerks ⚠️" [ref=e180] [cursor=pointer]:
- generic [ref=e181] [cursor=pointer]: Reservation & Transportation Ticket Agents & Travel Clerks
- generic [ref=e182] [cursor=pointer]: ⚠️
- button "Secondary School Teachers" [ref=e183] [cursor=pointer]:
- generic [ref=e184] [cursor=pointer]: Secondary School Teachers
- button "Self-Enrichment Teachers" [ref=e185] [cursor=pointer]:
- generic [ref=e186] [cursor=pointer]: Self-Enrichment Teachers
- button "Shampooers" [ref=e187] [cursor=pointer]:
- generic [ref=e188] [cursor=pointer]: Shampooers
- button "Skincare Specialists" [ref=e189] [cursor=pointer]:
- generic [ref=e190] [cursor=pointer]: Skincare Specialists
- button "Social & Human Service Assistants" [ref=e191] [cursor=pointer]:
- generic [ref=e192] [cursor=pointer]: Social & Human Service Assistants
- button "Teaching Assistants, Postsecondary" [ref=e193] [cursor=pointer]:
- generic [ref=e194] [cursor=pointer]: Teaching Assistants, Postsecondary
- button "Telephone Operators ⚠️" [ref=e195] [cursor=pointer]:
- generic [ref=e196] [cursor=pointer]: Telephone Operators
- generic [ref=e197] [cursor=pointer]: ⚠️
- button "Travel Guides ⚠️" [ref=e198] [cursor=pointer]:
- generic [ref=e199] [cursor=pointer]: Travel Guides
- generic [ref=e200] [cursor=pointer]: ⚠️
- button "Cooks, Fast Food ⚠️" [ref=e201] [cursor=pointer]:
- generic [ref=e202] [cursor=pointer]: Cooks, Fast Food
- generic [ref=e203] [cursor=pointer]: ⚠️
- button "Dishwashers ⚠️" [ref=e204] [cursor=pointer]:
- generic [ref=e205] [cursor=pointer]: Dishwashers
- generic [ref=e206] [cursor=pointer]: ⚠️
- button "Door-to-Door Sales Workers, News & Street Vendors, & Related Workers ⚠️" [ref=e207] [cursor=pointer]:
- generic [ref=e208] [cursor=pointer]: Door-to-Door Sales Workers, News & Street Vendors, & Related Workers
- generic [ref=e209] [cursor=pointer]: ⚠️
- button "Educational, Guidance, & Career Counselors & Advisors" [ref=e210] [cursor=pointer]:
- generic [ref=e211] [cursor=pointer]: Educational, Guidance, & Career Counselors & Advisors
- button "Helpers--Painters, Paperhangers, Plasterers, & Stucco Masons ⚠️" [ref=e212] [cursor=pointer]:
- generic [ref=e213] [cursor=pointer]: Helpers--Painters, Paperhangers, Plasterers, & Stucco Masons
- generic [ref=e214] [cursor=pointer]: ⚠️
- button "Hospitalists" [ref=e215] [cursor=pointer]:
- generic [ref=e216] [cursor=pointer]: Hospitalists
- button "Low Vision Therapists, Orientation & Mobility Specialists, & Vision Rehabilitation Therapists" [ref=e217] [cursor=pointer]:
- generic [ref=e218] [cursor=pointer]: Low Vision Therapists, Orientation & Mobility Specialists, & Vision Rehabilitation Therapists
- button "Maids & Housekeeping Cleaners ⚠️" [ref=e219] [cursor=pointer]:
- generic [ref=e220] [cursor=pointer]: Maids & Housekeeping Cleaners
- generic [ref=e221] [cursor=pointer]: ⚠️
- button "Nurse Midwives" [ref=e222] [cursor=pointer]:
- generic [ref=e223] [cursor=pointer]: Nurse Midwives
- button "Special Education Teachers, Preschool" [ref=e224] [cursor=pointer]:
- generic [ref=e225] [cursor=pointer]: Special Education Teachers, Preschool
- button "Substance Abuse & Behavioral Disorder Counselors ⚠️" [ref=e226] [cursor=pointer]:
- generic [ref=e227] [cursor=pointer]: Substance Abuse & Behavioral Disorder Counselors
- generic [ref=e228] [cursor=pointer]: ⚠️
- generic [ref=e229]:
- text: This page includes information from
- link "O*NET OnLine" [ref=e230] [cursor=pointer]:
- /url: https://www.onetcenter.org
- text: by the U.S. Department of Labor, Employment & Training Administration (USDOL/ETA). Used under the
- link "CC BY 4.0 license" [ref=e231] [cursor=pointer]:
- /url: https://creativecommons.org/licenses/by/4.0/
- text: . **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are enriched with resources from the
- link "Bureau of Labor Statistics" [ref=e232] [cursor=pointer]:
- /url: https://www.bls.gov
- text: and program information from the
- link "National Center for Education Statistics" [ref=e233] [cursor=pointer]:
- /url: https://nces.ed.gov
- text: .
- button "Open chat" [ref=e234] [cursor=pointer]:
- img [ref=e235] [cursor=pointer]
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -0,0 +1,253 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- navigation [ref=e6]:
- button "Find Your Career" [ref=e8] [cursor=pointer]
- button "Preparing & UpSkilling for Your Career" [ref=e10] [cursor=pointer]
- button "Enhancing Your Career(Premium)" [ref=e12] [cursor=pointer]:
- text: Enhancing Your Career
- generic [ref=e13] [cursor=pointer]: (Premium)
- button "Retirement Planning (beta)(Premium)" [ref=e15] [cursor=pointer]:
- text: Retirement Planning (beta)
- generic [ref=e16] [cursor=pointer]: (Premium)
- button "Profile" [ref=e18] [cursor=pointer]
- generic [ref=e19]:
- button "Upgrade to Premium" [ref=e20] [cursor=pointer]
- button "Support" [ref=e21] [cursor=pointer]
- button "Logout" [ref=e22] [cursor=pointer]
- main [ref=e23]:
- generic [ref=e24]:
- generic [ref=e25]:
- heading "Explore Careers - use these tools to find your best fit" [level=2] [ref=e26]
- generic [ref=e27]:
- generic [ref=e28]:
- text: Search for Career
- generic [ref=e29]: "*"
- combobox "Start typing a career..." [ref=e31]
- paragraph [ref=e32]: Please pick from the dropdown when performing search. Our database is very comprehensive but cant accommodate every job title—choose the closest match to what youre searching for.
- generic [ref=e33]:
- heading "Career Comparison" [level=2] [ref=e34]
- button "Edit priorities" [ref=e35] [cursor=pointer]
- paragraph [ref=e36]: No careers added to comparison.
- generic [ref=e37]:
- combobox [ref=e38]:
- option "All Preparation Levels" [selected]
- option "Little or No Preparation"
- option "Some Preparation Needed"
- option "Medium Preparation Needed"
- option "Considerable Preparation Needed"
- option "Extensive Preparation Needed"
- combobox [ref=e39]:
- option "All Fit Levels" [selected]
- option "Best - Very Strong Match"
- option "Great - Strong Match"
- option "Good - Less Strong Match"
- button "Reload Career Suggestions" [ref=e40] [cursor=pointer]
- generic [ref=e41]:
- generic [ref=e42]: ⚠️
- generic [ref=e43]: = May have limited data for this career path
- generic [ref=e44]:
- button "Amusement & Recreation Attendants ⚠️" [ref=e45] [cursor=pointer]:
- generic [ref=e46] [cursor=pointer]: Amusement & Recreation Attendants
- generic [ref=e47] [cursor=pointer]: ⚠️
- button "Baristas ⚠️" [ref=e48] [cursor=pointer]:
- generic [ref=e49] [cursor=pointer]: Baristas
- generic [ref=e50] [cursor=pointer]: ⚠️
- button "Bus Drivers, School" [ref=e51] [cursor=pointer]:
- generic [ref=e52] [cursor=pointer]: Bus Drivers, School
- button "Childcare Workers" [ref=e53] [cursor=pointer]:
- generic [ref=e54] [cursor=pointer]: Childcare Workers
- button "Coaches & Scouts" [ref=e55] [cursor=pointer]:
- generic [ref=e56] [cursor=pointer]: Coaches & Scouts
- button "Concierges" [ref=e57] [cursor=pointer]:
- generic [ref=e58] [cursor=pointer]: Concierges
- button "Exercise Trainers & Group Fitness Instructors" [ref=e59] [cursor=pointer]:
- generic [ref=e60] [cursor=pointer]: Exercise Trainers & Group Fitness Instructors
- button "Food Servers, Nonrestaurant ⚠️" [ref=e61] [cursor=pointer]:
- generic [ref=e62] [cursor=pointer]: Food Servers, Nonrestaurant
- generic [ref=e63] [cursor=pointer]: ⚠️
- button "Funeral Attendants ⚠️" [ref=e64] [cursor=pointer]:
- generic [ref=e65] [cursor=pointer]: Funeral Attendants
- generic [ref=e66] [cursor=pointer]: ⚠️
- button "Home Health Aides ⚠️" [ref=e67] [cursor=pointer]:
- generic [ref=e68] [cursor=pointer]: Home Health Aides
- generic [ref=e69] [cursor=pointer]: ⚠️
- button "Hosts & Hostesses, Restaurant, Lounge, & Coffee Shop ⚠️" [ref=e70] [cursor=pointer]:
- generic [ref=e71] [cursor=pointer]: Hosts & Hostesses, Restaurant, Lounge, & Coffee Shop
- generic [ref=e72] [cursor=pointer]: ⚠️
- button "Locker Room, Coatroom, & Dressing Room Attendants ⚠️" [ref=e73] [cursor=pointer]:
- generic [ref=e74] [cursor=pointer]: Locker Room, Coatroom, & Dressing Room Attendants
- generic [ref=e75] [cursor=pointer]: ⚠️
- button "Nannies" [ref=e76] [cursor=pointer]:
- generic [ref=e77] [cursor=pointer]: Nannies
- button "Nursing Assistants" [ref=e78] [cursor=pointer]:
- generic [ref=e79] [cursor=pointer]: Nursing Assistants
- button "Occupational Therapy Aides" [ref=e80] [cursor=pointer]:
- generic [ref=e81] [cursor=pointer]: Occupational Therapy Aides
- button "Passenger Attendants ⚠️" [ref=e82] [cursor=pointer]:
- generic [ref=e83] [cursor=pointer]: Passenger Attendants
- generic [ref=e84] [cursor=pointer]: ⚠️
- button "Personal Care Aides ⚠️" [ref=e85] [cursor=pointer]:
- generic [ref=e86] [cursor=pointer]: Personal Care Aides
- generic [ref=e87] [cursor=pointer]: ⚠️
- button "Physical Therapist Aides" [ref=e88] [cursor=pointer]:
- generic [ref=e89] [cursor=pointer]: Physical Therapist Aides
- button "Recreation Workers" [ref=e90] [cursor=pointer]:
- generic [ref=e91] [cursor=pointer]: Recreation Workers
- button "Residential Advisors" [ref=e92] [cursor=pointer]:
- generic [ref=e93] [cursor=pointer]: Residential Advisors
- button "School Bus Monitors ⚠️" [ref=e94] [cursor=pointer]:
- generic [ref=e95] [cursor=pointer]: School Bus Monitors
- generic [ref=e96] [cursor=pointer]: ⚠️
- button "Substitute Teachers, Short-Term ⚠️" [ref=e97] [cursor=pointer]:
- generic [ref=e98] [cursor=pointer]: Substitute Teachers, Short-Term
- generic [ref=e99] [cursor=pointer]: ⚠️
- button "Teaching Assistants, Preschool, Elementary, Middle, & Secondary School ⚠️" [ref=e100] [cursor=pointer]:
- generic [ref=e101] [cursor=pointer]: Teaching Assistants, Preschool, Elementary, Middle, & Secondary School
- generic [ref=e102] [cursor=pointer]: ⚠️
- button "Teaching Assistants, Special Education ⚠️" [ref=e103] [cursor=pointer]:
- generic [ref=e104] [cursor=pointer]: Teaching Assistants, Special Education
- generic [ref=e105] [cursor=pointer]: ⚠️
- button "Tour Guides & Escorts ⚠️" [ref=e106] [cursor=pointer]:
- generic [ref=e107] [cursor=pointer]: Tour Guides & Escorts
- generic [ref=e108] [cursor=pointer]: ⚠️
- button "Ushers, Lobby Attendants, & Ticket Takers ⚠️" [ref=e109] [cursor=pointer]:
- generic [ref=e110] [cursor=pointer]: Ushers, Lobby Attendants, & Ticket Takers
- generic [ref=e111] [cursor=pointer]: ⚠️
- button "Waiters & Waitresses ⚠️" [ref=e112] [cursor=pointer]:
- generic [ref=e113] [cursor=pointer]: Waiters & Waitresses
- generic [ref=e114] [cursor=pointer]: ⚠️
- button "Adapted Physical Education Specialists" [ref=e115] [cursor=pointer]:
- generic [ref=e116] [cursor=pointer]: Adapted Physical Education Specialists
- button "Adult Basic Education, Adult Secondary Education, & English as a Second Language Instructors" [ref=e117] [cursor=pointer]:
- generic [ref=e118] [cursor=pointer]: Adult Basic Education, Adult Secondary Education, & English as a Second Language Instructors
- button "Athletes & Sports Competitors" [ref=e119] [cursor=pointer]:
- generic [ref=e120] [cursor=pointer]: Athletes & Sports Competitors
- button "Baggage Porters & Bellhops ⚠️" [ref=e121] [cursor=pointer]:
- generic [ref=e122] [cursor=pointer]: Baggage Porters & Bellhops
- generic [ref=e123] [cursor=pointer]: ⚠️
- button "Barbers" [ref=e124] [cursor=pointer]:
- generic [ref=e125] [cursor=pointer]: Barbers
- button "Bartenders" [ref=e126] [cursor=pointer]:
- generic [ref=e127] [cursor=pointer]: Bartenders
- button "Bus Drivers, Transit & Intercity" [ref=e128] [cursor=pointer]:
- generic [ref=e129] [cursor=pointer]: Bus Drivers, Transit & Intercity
- button "Career/Technical Education Teachers, Middle School" [ref=e130] [cursor=pointer]:
- generic [ref=e131] [cursor=pointer]: Career/Technical Education Teachers, Middle School
- button "Career/Technical Education Teachers, Secondary School" [ref=e132] [cursor=pointer]:
- generic [ref=e133] [cursor=pointer]: Career/Technical Education Teachers, Secondary School
- button "Clergy" [ref=e134] [cursor=pointer]:
- generic [ref=e135] [cursor=pointer]: Clergy
- button "Cooks, Private Household" [ref=e136] [cursor=pointer]:
- generic [ref=e137] [cursor=pointer]: Cooks, Private Household
- button "Correctional Officers & Jailers" [ref=e138] [cursor=pointer]:
- generic [ref=e139] [cursor=pointer]: Correctional Officers & Jailers
- button "Dietetic Technicians" [ref=e140] [cursor=pointer]:
- generic [ref=e141] [cursor=pointer]: Dietetic Technicians
- button "Dining Room & Cafeteria Attendants & Bartender Helpers ⚠️" [ref=e142] [cursor=pointer]:
- generic [ref=e143] [cursor=pointer]: Dining Room & Cafeteria Attendants & Bartender Helpers
- generic [ref=e144] [cursor=pointer]: ⚠️
- button "Elementary School Teachers" [ref=e145] [cursor=pointer]:
- generic [ref=e146] [cursor=pointer]: Elementary School Teachers
- button "Fast Food & Counter Workers ⚠️" [ref=e147] [cursor=pointer]:
- generic [ref=e148] [cursor=pointer]: Fast Food & Counter Workers
- generic [ref=e149] [cursor=pointer]: ⚠️
- button "Fitness & Wellness Coordinators" [ref=e150] [cursor=pointer]:
- generic [ref=e151] [cursor=pointer]: Fitness & Wellness Coordinators
- button "Flight Attendants" [ref=e152] [cursor=pointer]:
- generic [ref=e153] [cursor=pointer]: Flight Attendants
- button "Hairdressers, Hairstylists, & Cosmetologists" [ref=e154] [cursor=pointer]:
- generic [ref=e155] [cursor=pointer]: Hairdressers, Hairstylists, & Cosmetologists
- button "Hotel, Motel, & Resort Desk Clerks ⚠️" [ref=e156] [cursor=pointer]:
- generic [ref=e157] [cursor=pointer]: Hotel, Motel, & Resort Desk Clerks
- generic [ref=e158] [cursor=pointer]: ⚠️
- button "Kindergarten Teachers" [ref=e159] [cursor=pointer]:
- generic [ref=e160] [cursor=pointer]: Kindergarten Teachers
- button "Licensed Practical & Licensed Vocational Nurses" [ref=e161] [cursor=pointer]:
- generic [ref=e162] [cursor=pointer]: Licensed Practical & Licensed Vocational Nurses
- button "Middle School Teachers" [ref=e163] [cursor=pointer]:
- generic [ref=e164] [cursor=pointer]: Middle School Teachers
- button "Midwives" [ref=e165] [cursor=pointer]:
- generic [ref=e166] [cursor=pointer]: Midwives
- button "Morticians, Undertakers, & Funeral Arrangers" [ref=e167] [cursor=pointer]:
- generic [ref=e168] [cursor=pointer]: Morticians, Undertakers, & Funeral Arrangers
- button "Occupational Therapy Assistants" [ref=e169] [cursor=pointer]:
- generic [ref=e170] [cursor=pointer]: Occupational Therapy Assistants
- button "Orderlies ⚠️" [ref=e171] [cursor=pointer]:
- generic [ref=e172] [cursor=pointer]: Orderlies
- generic [ref=e173] [cursor=pointer]: ⚠️
- button "Physical Therapist Assistants" [ref=e174] [cursor=pointer]:
- generic [ref=e175] [cursor=pointer]: Physical Therapist Assistants
- button "Preschool Teachers" [ref=e176] [cursor=pointer]:
- generic [ref=e177] [cursor=pointer]: Preschool Teachers
- button "Psychiatric Aides" [ref=e178] [cursor=pointer]:
- generic [ref=e179] [cursor=pointer]: Psychiatric Aides
- button "Reservation & Transportation Ticket Agents & Travel Clerks ⚠️" [ref=e180] [cursor=pointer]:
- generic [ref=e181] [cursor=pointer]: Reservation & Transportation Ticket Agents & Travel Clerks
- generic [ref=e182] [cursor=pointer]: ⚠️
- button "Secondary School Teachers" [ref=e183] [cursor=pointer]:
- generic [ref=e184] [cursor=pointer]: Secondary School Teachers
- button "Self-Enrichment Teachers" [ref=e185] [cursor=pointer]:
- generic [ref=e186] [cursor=pointer]: Self-Enrichment Teachers
- button "Shampooers" [ref=e187] [cursor=pointer]:
- generic [ref=e188] [cursor=pointer]: Shampooers
- button "Skincare Specialists" [ref=e189] [cursor=pointer]:
- generic [ref=e190] [cursor=pointer]: Skincare Specialists
- button "Social & Human Service Assistants" [ref=e191] [cursor=pointer]:
- generic [ref=e192] [cursor=pointer]: Social & Human Service Assistants
- button "Teaching Assistants, Postsecondary" [ref=e193] [cursor=pointer]:
- generic [ref=e194] [cursor=pointer]: Teaching Assistants, Postsecondary
- button "Telephone Operators ⚠️" [ref=e195] [cursor=pointer]:
- generic [ref=e196] [cursor=pointer]: Telephone Operators
- generic [ref=e197] [cursor=pointer]: ⚠️
- button "Travel Guides ⚠️" [ref=e198] [cursor=pointer]:
- generic [ref=e199] [cursor=pointer]: Travel Guides
- generic [ref=e200] [cursor=pointer]: ⚠️
- button "Cooks, Fast Food ⚠️" [ref=e201] [cursor=pointer]:
- generic [ref=e202] [cursor=pointer]: Cooks, Fast Food
- generic [ref=e203] [cursor=pointer]: ⚠️
- button "Dishwashers ⚠️" [ref=e204] [cursor=pointer]:
- generic [ref=e205] [cursor=pointer]: Dishwashers
- generic [ref=e206] [cursor=pointer]: ⚠️
- button "Door-to-Door Sales Workers, News & Street Vendors, & Related Workers ⚠️" [ref=e207] [cursor=pointer]:
- generic [ref=e208] [cursor=pointer]: Door-to-Door Sales Workers, News & Street Vendors, & Related Workers
- generic [ref=e209] [cursor=pointer]: ⚠️
- button "Educational, Guidance, & Career Counselors & Advisors" [ref=e210] [cursor=pointer]:
- generic [ref=e211] [cursor=pointer]: Educational, Guidance, & Career Counselors & Advisors
- button "Helpers--Painters, Paperhangers, Plasterers, & Stucco Masons ⚠️" [ref=e212] [cursor=pointer]:
- generic [ref=e213] [cursor=pointer]: Helpers--Painters, Paperhangers, Plasterers, & Stucco Masons
- generic [ref=e214] [cursor=pointer]: ⚠️
- button "Hospitalists" [ref=e215] [cursor=pointer]:
- generic [ref=e216] [cursor=pointer]: Hospitalists
- button "Low Vision Therapists, Orientation & Mobility Specialists, & Vision Rehabilitation Therapists" [ref=e217] [cursor=pointer]:
- generic [ref=e218] [cursor=pointer]: Low Vision Therapists, Orientation & Mobility Specialists, & Vision Rehabilitation Therapists
- button "Maids & Housekeeping Cleaners ⚠️" [ref=e219] [cursor=pointer]:
- generic [ref=e220] [cursor=pointer]: Maids & Housekeeping Cleaners
- generic [ref=e221] [cursor=pointer]: ⚠️
- button "Nurse Midwives" [ref=e222] [cursor=pointer]:
- generic [ref=e223] [cursor=pointer]: Nurse Midwives
- button "Special Education Teachers, Preschool" [ref=e224] [cursor=pointer]:
- generic [ref=e225] [cursor=pointer]: Special Education Teachers, Preschool
- button "Substance Abuse & Behavioral Disorder Counselors ⚠️" [ref=e226] [cursor=pointer]:
- generic [ref=e227] [cursor=pointer]: Substance Abuse & Behavioral Disorder Counselors
- generic [ref=e228] [cursor=pointer]: ⚠️
- generic [ref=e229]:
- text: This page includes information from
- link "O*NET OnLine" [ref=e230] [cursor=pointer]:
- /url: https://www.onetcenter.org
- text: by the U.S. Department of Labor, Employment & Training Administration (USDOL/ETA). Used under the
- link "CC BY 4.0 license" [ref=e231] [cursor=pointer]:
- /url: https://creativecommons.org/licenses/by/4.0/
- text: . **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are enriched with resources from the
- link "Bureau of Labor Statistics" [ref=e232] [cursor=pointer]:
- /url: https://www.bls.gov
- text: and program information from the
- link "National Center for Education Statistics" [ref=e233] [cursor=pointer]:
- /url: https://nces.ed.gov
- text: .
- button "Open chat" [ref=e234] [cursor=pointer]:
- img [ref=e235] [cursor=pointer]
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@ -0,0 +1,34 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- navigation [ref=e6]:
- button "Find Your Career" [ref=e8] [cursor=pointer]
- button "Preparing & UpSkilling for Your Career" [ref=e10] [cursor=pointer]
- button "Enhancing Your Career(Premium)" [ref=e12] [cursor=pointer]:
- text: Enhancing Your Career
- generic [ref=e13] [cursor=pointer]: (Premium)
- button "Retirement Planning (beta)(Premium)" [ref=e15] [cursor=pointer]:
- text: Retirement Planning (beta)
- generic [ref=e16] [cursor=pointer]: (Premium)
- button "Profile" [ref=e18] [cursor=pointer]
- generic [ref=e19]:
- button "Upgrade to Premium" [ref=e20] [cursor=pointer]
- button "Support" [ref=e21] [cursor=pointer]
- button "Logout" [ref=e22] [cursor=pointer]
- main [ref=e23]:
- generic [ref=e24]:
- heading "Educational Programs" [level=2] [ref=e25]
- paragraph [ref=e26]: "You have not selected a career yet. Please search for one below:"
- generic [ref=e27]:
- generic [ref=e28]:
- text: Search for Career
- generic [ref=e29]: "*"
- combobox "Start typing a career..." [ref=e31]
- paragraph [ref=e32]: Please pick from the dropdown when performing search. Our database is very comprehensive but cant accommodate every job title—choose the closest match to what youre searching for.
- paragraph [ref=e33]: After you pick a career, well display matching educational programs.
- button "Open chat" [ref=e34] [cursor=pointer]:
- img [ref=e35] [cursor=pointer]
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -0,0 +1,34 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- navigation [ref=e6]:
- button "Find Your Career" [ref=e8] [cursor=pointer]
- button "Preparing & UpSkilling for Your Career" [ref=e10] [cursor=pointer]
- button "Enhancing Your Career(Premium)" [ref=e12] [cursor=pointer]:
- text: Enhancing Your Career
- generic [ref=e13] [cursor=pointer]: (Premium)
- button "Retirement Planning (beta)(Premium)" [ref=e15] [cursor=pointer]:
- text: Retirement Planning (beta)
- generic [ref=e16] [cursor=pointer]: (Premium)
- button "Profile" [ref=e18] [cursor=pointer]
- generic [ref=e19]:
- button "Upgrade to Premium" [ref=e20] [cursor=pointer]
- button "Support" [ref=e21] [cursor=pointer]
- button "Logout" [ref=e22] [cursor=pointer]
- main [ref=e23]:
- generic [ref=e24]:
- heading "Educational Programs" [level=2] [ref=e25]
- paragraph [ref=e26]: "You have not selected a career yet. Please search for one below:"
- generic [ref=e27]:
- generic [ref=e28]:
- text: Search for Career
- generic [ref=e29]: "*"
- combobox "Start typing a career..." [ref=e31]
- paragraph [ref=e32]: Please pick from the dropdown when performing search. Our database is very comprehensive but cant accommodate every job title—choose the closest match to what youre searching for.
- paragraph [ref=e33]: After you pick a career, well display matching educational programs.
- button "Open chat" [ref=e34] [cursor=pointer]:
- img [ref=e35] [cursor=pointer]
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

View File

@ -0,0 +1,92 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- navigation [ref=e6]:
- button "Find Your Career" [ref=e8] [cursor=pointer]
- button "Preparing & UpSkilling for Your Career" [ref=e10] [cursor=pointer]
- button "Enhancing Your Career(Premium)" [ref=e12] [cursor=pointer]:
- text: Enhancing Your Career
- generic [ref=e13] [cursor=pointer]: (Premium)
- button "Retirement Planning (beta)(Premium)" [ref=e15] [cursor=pointer]:
- text: Retirement Planning (beta)
- generic [ref=e16] [cursor=pointer]: (Premium)
- button "Profile" [ref=e18] [cursor=pointer]
- generic [ref=e19]:
- button "Upgrade to Premium" [ref=e20] [cursor=pointer]
- button "Support" [ref=e21] [cursor=pointer]
- button "Logout" [ref=e22] [cursor=pointer]
- main [ref=e23]:
- generic [ref=e24]:
- generic [ref=e25]:
- heading "Explore Careers - use these tools to find your best fit" [level=2] [ref=e26]
- generic [ref=e27]:
- generic [ref=e28]:
- text: Search for Career
- generic [ref=e29]: "*"
- combobox "Start typing a career..." [active] [ref=e31]: cu
- paragraph [ref=e32]: Please pick from the dropdown when performing search. Our database is very comprehensive but cant accommodate every job title—choose the closest match to what youre searching for.
- generic [ref=e33]:
- heading "Career Comparison" [level=2] [ref=e34]
- button "Edit priorities" [ref=e35] [cursor=pointer]
- table [ref=e36]:
- rowgroup [ref=e37]:
- row "Career interests meaning stability growth balance recognition Match Actions" [ref=e38]:
- cell "Career" [ref=e39]
- cell "interests" [ref=e40]
- cell "meaning" [ref=e41]
- cell "stability" [ref=e42]
- cell "growth" [ref=e43]
- cell "balance" [ref=e44]
- cell "recognition" [ref=e45]
- cell "Match" [ref=e46]
- cell "Actions" [ref=e47]
- rowgroup [ref=e48]:
- row "Amusement & Recreation Attendants 5 3 1 3 3 3 53.8% Remove Plan your Education/Skills" [ref=e49]:
- cell "Amusement & Recreation Attendants" [ref=e50]
- cell "5" [ref=e51]
- cell "3" [ref=e52]
- cell "1" [ref=e53]
- cell "3" [ref=e54]
- cell "3" [ref=e55]
- cell "3" [ref=e56]
- cell "53.8%" [ref=e57]
- cell "Remove Plan your Education/Skills" [ref=e58]:
- button "Remove" [ref=e59] [cursor=pointer]
- button "Plan your Education/Skills" [ref=e60] [cursor=pointer]
- generic [ref=e61]:
- combobox [ref=e62]:
- option "All Preparation Levels" [selected]
- option "Little or No Preparation"
- option "Some Preparation Needed"
- option "Medium Preparation Needed"
- option "Considerable Preparation Needed"
- option "Extensive Preparation Needed"
- combobox [ref=e63]:
- option "All Fit Levels" [selected]
- option "Best - Very Strong Match"
- option "Great - Strong Match"
- option "Good - Less Strong Match"
- button "Reload Career Suggestions" [ref=e64] [cursor=pointer]
- generic [ref=e65]:
- generic [ref=e66]: ⚠️
- generic [ref=e67]: = May have limited data for this career path
- generic [ref=e69]:
- text: This page includes information from
- link "O*NET OnLine" [ref=e70] [cursor=pointer]:
- /url: https://www.onetcenter.org
- text: by the U.S. Department of Labor, Employment & Training Administration (USDOL/ETA). Used under the
- link "CC BY 4.0 license" [ref=e71] [cursor=pointer]:
- /url: https://creativecommons.org/licenses/by/4.0/
- text: . **O*NET®** is a trademark of USDOL/ETA. Salary and employment data are enriched with resources from the
- link "Bureau of Labor Statistics" [ref=e72] [cursor=pointer]:
- /url: https://www.bls.gov
- text: and program information from the
- link "National Center for Education Statistics" [ref=e73] [cursor=pointer]:
- /url: https://nces.ed.gov
- text: .
- button "Open chat" [ref=e74] [cursor=pointer]:
- img [ref=e75] [cursor=pointer]
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

View File

@ -0,0 +1,22 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e8]:
- heading "Sign In" [level=1] [ref=e9]
- paragraph [ref=e10]: Unexpected token 'T', "Too many r"... is not valid JSON
- generic [ref=e11]:
- textbox "Username" [ref=e12]: test_u202509111029199314
- textbox "Password" [ref=e13]: P@ssw0rd!9314
- button "Sign In" [active] [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,23 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e7]:
- generic [ref=e8]: Your session has expired. Please sign in again.
- generic [ref=e9]:
- heading "Sign In" [level=1] [ref=e10]
- generic [ref=e11]:
- textbox "Username" [ref=e12]
- textbox "Password" [ref=e13]
- button "Sign In" [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,23 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e7]:
- generic [ref=e8]: Your session has expired. Please sign in again.
- generic [ref=e9]:
- heading "Sign In" [level=1] [ref=e10]
- generic [ref=e11]:
- textbox "Username" [ref=e12]
- textbox "Password" [ref=e13]
- button "Sign In" [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@ -0,0 +1,22 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e8]:
- heading "Sign In" [level=1] [ref=e9]
- paragraph [ref=e10]: Unexpected token 'T', "Too many r"... is not valid JSON
- generic [ref=e11]:
- textbox "Username" [ref=e12]: test_u202509111029199314
- textbox "Password" [ref=e13]: P@ssw0rd!9314
- button "Sign In" [active] [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,22 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e8]:
- heading "Sign In" [level=1] [ref=e9]
- paragraph [ref=e10]: Unexpected token 'T', "Too many r"... is not valid JSON
- generic [ref=e11]:
- textbox "Username" [ref=e12]: test_u202509111029199314
- textbox "Password" [ref=e13]: P@ssw0rd!9314
- button "Sign In" [active] [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,22 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e8]:
- heading "Sign In" [level=1] [ref=e9]
- paragraph [ref=e10]: Unexpected token 'T', "Too many r"... is not valid JSON
- generic [ref=e11]:
- textbox "Username" [ref=e12]: test_u202509111029199314
- textbox "Password" [ref=e13]: P@ssw0rd!9314
- button "Sign In" [active] [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,22 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e8]:
- heading "Sign In" [level=1] [ref=e9]
- paragraph [ref=e10]: Unexpected token 'T', "Too many r"... is not valid JSON
- generic [ref=e11]:
- textbox "Username" [ref=e12]: test_u202509111029199314
- textbox "Password" [ref=e13]: P@ssw0rd!9314
- button "Sign In" [active] [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,22 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e8]:
- heading "Sign In" [level=1] [ref=e9]
- paragraph [ref=e10]: Unexpected token 'T', "Too many r"... is not valid JSON
- generic [ref=e11]:
- textbox "Username" [ref=e12]: test_u202509111029199314
- textbox "Password" [ref=e13]: P@ssw0rd!9314
- button "Sign In" [active] [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,22 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e8]:
- heading "Sign In" [level=1] [ref=e9]
- paragraph [ref=e10]: Unexpected token 'T', "Too many r"... is not valid JSON
- generic [ref=e11]:
- textbox "Username" [ref=e12]: test_u202509111029199314
- textbox "Password" [ref=e13]: P@ssw0rd!9314
- button "Sign In" [active] [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,22 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- main [ref=e6]:
- generic [ref=e8]:
- heading "Sign In" [level=1] [ref=e9]
- paragraph [ref=e10]: Unexpected token 'T', "Too many r"... is not valid JSON
- generic [ref=e11]:
- textbox "Username" [ref=e12]: test_u202509111029199314
- textbox "Password" [ref=e13]: P@ssw0rd!9314
- button "Sign In" [active] [ref=e14] [cursor=pointer]
- generic [ref=e15]:
- paragraph [ref=e16]:
- text: Dont have an account?
- link "Sign Up" [ref=e17] [cursor=pointer]:
- /url: /signup
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
- /url: /forgot-password
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -0,0 +1,28 @@
# Page snapshot
```yaml
- generic [ref=e3]:
- banner [ref=e4]:
- heading "AptivaAI - Career Guidance Platform" [level=1] [ref=e5]
- navigation [ref=e6]:
- button "Find Your Career" [ref=e8] [cursor=pointer]
- button "Preparing & UpSkilling for Your Career" [ref=e10] [cursor=pointer]
- button "Enhancing Your Career(Premium)" [ref=e12] [cursor=pointer]:
- text: Enhancing Your Career
- generic [ref=e13] [cursor=pointer]: (Premium)
- button "Retirement Planning (beta)(Premium)" [ref=e15] [cursor=pointer]:
- text: Retirement Planning (beta)
- generic [ref=e16] [cursor=pointer]: (Premium)
- button "Profile" [ref=e18] [cursor=pointer]
- generic [ref=e19]:
- button "Support" [ref=e20] [cursor=pointer]
- button "Logout" [ref=e21] [cursor=pointer]
- main [ref=e22]:
- generic [ref=e23]:
- 'heading "Your plan: Premium" [level=2] [ref=e24]'
- paragraph [ref=e25]: Manage payment method, invoices or cancel anytime.
- button "Manage subscription" [ref=e26] [cursor=pointer]
- button "Back to app" [ref=e27] [cursor=pointer]
- button "Open chat" [ref=e28] [cursor=pointer]:
- img [ref=e29] [cursor=pointer]
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@ -0,0 +1,109 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { saveTestUser } from '../utils/testUser.js';
function uniq() {
const t = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14);
return `u${t}${Math.floor(Math.random() * 1e4)}`;
}
test.describe('@p0 SignUp → Journey select → Route', () => {
test.setTimeout(10000); // allow for slower first load + areas fetch
test('create a new user via UI and persist creds for later specs', async ({ page }) => {
const u = uniq();
const user = {
username: `test_${u}`,
password: `P@ssw0rd!${u.slice(-4)}`,
firstname: 'Test',
lastname: 'User',
email: `jcoakley@aptivaai.com`,
phone: '+16787696633',
zipcode: '30301',
stateVal: 'GA', // Georgia (has areas)
journeyTitle: 'Planning Your Career', // safe non-premium
journeyRoute: '/planning',
};
// Start clean
await page.context().clearCookies();
await page.goto('/signup', { waitUntil: 'networkidle' });
// Make sure the Sign Up form is visible
await expect(page.getByRole('heading', { name: /Sign Up/i })).toBeVisible();
// Fill form
await page.getByPlaceholder('Username').fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByPlaceholder('Retype Password', { exact: true }).fill(user.password);
await page.getByPlaceholder('First Name').fill(user.firstname);
await page.getByPlaceholder('Last Name').fill(user.lastname);
await page.getByPlaceholder('Email', { exact: true }).fill(user.email);
await page.getByPlaceholder('Retype Email', { exact: true }).fill(user.email);
await page.getByPlaceholder('+15555555555').fill(user.phone);
await page.getByPlaceholder('Zip Code').fill(user.zipcode);
// Select State (the dropdown that has “Select State” as its placeholder option)
const stateSelect = page.locator('select').filter({
has: page.locator('option', { hasText: 'Select State' }),
});
await expect(stateSelect).toBeVisible();
await stateSelect.selectOption(user.stateVal);
// Areas: MUST select one (validateFields requires area)
const areaSelect = page.locator('select#area');
await expect(areaSelect).toBeVisible();
// wait for the debounced / aborted fetch chain to complete for this state
const stateParam = encodeURIComponent(user.stateVal);
await page.waitForResponse(
r => r.url().includes(`/api/areas?state=${stateParam}`) && r.request().method() === 'GET',
{ timeout: 20000 }
);
// the select is disabled while loading; wait until it's enabled and populated
await expect(areaSelect).toBeEnabled({ timeout: 10000 });
await expect(async () => {
const count = await areaSelect.locator('option').count();
expect(count).toBeGreaterThan(1); // placeholder + at least one real option
}).toPass({ timeout: 10000 });
// choose first non-empty option
let choseArea = false;
const options = areaSelect.locator('option');
const n = await options.count();
for (let i = 0; i < n; i++) {
const val = await options.nth(i).getAttribute('value');
if (val) { await areaSelect.selectOption(val); choseArea = true; break; }
}
expect(choseArea).toBeTruthy(); // fail fast if areas didnt load
// Next → shows situation cards
await page.getByRole('button', { name: /^Next$/ }).click();
// Pick journey card (Planning Your Career)
await page.getByRole('heading', { name: /Where are you in your career journey/i }).waitFor();
// Click the journey card by its visible text (cards are not role=button)
const journeyCard = page.getByText(user.journeyTitle, { exact: true });
await expect(journeyCard).toBeVisible();
await journeyCard.click();
// Confirm modal → Confirm
await expect(page.getByRole('button', { name: /^Confirm$/ })).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /^Confirm$/ }).click();
// Expect navigation to journey route (e.g., /planning)
await page.waitForURL(`**${user.journeyRoute}**`, { timeout: 10000 });
// Persist credentials for later specs
saveTestUser({ ...user, choseArea });
// Sanity: cookie session (if register logs-in server-side)
const cookies = await page.context().cookies();
expect(cookies.some((c) => /jwt|session/i.test(c.name))).toBeTruthy();
// No console errors
const errors = [];
page.on('console', (m) => {
if (m.type() === 'error') errors.push(m.text());
});
expect(errors).toEqual([]);
});
});

View File

@ -0,0 +1,38 @@
// tests/e2e/02-signin-landing.spec.mjs
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 SignIn → Landing', () => {
test.setTimeout(10000);
test('signs in with persisted user and reaches SignInLanding', async ({ page }) => {
const user = loadTestUser();
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Sign In/i })).toBeVisible();
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
await expect(
page.getByRole('heading', { name: new RegExp(`Welcome to AptivaAI\\s+${user.firstname}!`) })
).toBeVisible();
await expect(page.getByRole('link', { name: /Go to Exploring/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Go to Preparing/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Go to Enhancing/i })).toBeVisible();
await expect(page.getByRole('link', { name: /Go to Retirement/i })).toBeVisible();
const cookies = await page.context().cookies();
expect(cookies.some(c => /jwt|session/i.test(c.name))).toBeTruthy();
const consoleErrors = [];
page.on('console', m => { if (m.type() === 'error') consoleErrors.push(m.text()); });
expect(consoleErrors).toEqual([]);
});
});

View File

@ -0,0 +1,58 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Interest Inventory → Career Explorer', () => {
test.setTimeout(20000);
test('answer (randomize on dev) → submit → land on Explorer', async ({ page }) => {
const user = loadTestUser();
// Sign in (fresh context each test)
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Sign In/i })).toBeVisible();
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Interest Inventory
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Interest Inventory/i })).toBeVisible();
// Wait for questions to render (page 1 of 10, 6 selects)
await expect(page.getByText(/Page\s+1\s+of\s+10/i)).toBeVisible();
await expect(page.locator('select')).toHaveCount(6, { timeout: 10000 });
// Dev-only helper: Randomize answers
const randomizeBtn = page.getByRole('button', { name: /Randomize Answers/i });
if (await randomizeBtn.isVisible()) {
await randomizeBtn.click();
await expect(page.getByText(/60\s*\/\s*60\s*answered/i)).toBeVisible();
} else {
// Fallback: fill current page with "Neutral" (3), then next; repeat (rare on prod)
for (let p = 0; p < 10; p++) {
const selects = page.locator('select');
const n = await selects.count();
for (let i = 0; i < n; i++) await selects.nth(i).selectOption('3');
if (p < 9) await page.getByRole('button', { name: /^Next$/ }).click();
}
}
// Move to last page if needed (randomize doesn't jump pages)
for (let i = 0; i < 9; i++) {
const next = page.getByRole('button', { name: /^Next$/ });
if (await next.isVisible()) await next.click();
}
// Submit
await page.getByRole('button', { name: /^Submit$/ }).click();
// Land on Career Explorer
await page.waitForURL('**/career-explorer**', { timeout: 20000 });
await expect(
page.getByRole('heading', { name: /Explore Careers - use these tools/i })
).toBeVisible({ timeout: 20000 });
});
});

View File

@ -0,0 +1,305 @@
// tests/e2e/04-career-explorer.core.spec.mjs
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Career Explorer core', () => {
// Enough headroom for cold suggestion builds, but still bounded.
test.setTimeout(40000);
test('suggestions visible (or reload) → open modal → add to comparison (+event bridge)', async ({ page }) => {
const user = loadTestUser();
const TIME = {
overlayAppear: 2000, // overlay should show quickly if it appears
cache: 60000, // wait for cache to populate after reload
tile: 8000, // find a tile quickly once cache exists
confirm: 7000, // modal/button appearances
tableRow: 20000, // time for table update after Save
};
// Accept alerts: inventory prompt and possible "already in comparison" duplicate
let sawInventoryAlert = false;
let sawDuplicateAlert = false;
page.on('dialog', async d => {
const msg = d.message();
if (/Interest Inventory/i.test(msg)) sawInventoryAlert = true;
if (/already in comparison/i.test(msg)) sawDuplicateAlert = true;
await d.accept();
});
// Helper: close any blocking overlay (priorities / meaning) by saving neutral defaults.
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dialog = overlay.locator('div[role="dialog"], div.bg-white').first();
// Select first non-empty option in each <select> (if any)
const selects = dialog.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const opts = selects.nth(i).locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await selects.nth(i).selectOption(v); break; }
}
}
// If theres a textbox, enter neutral “3”
const tb = dialog.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
// Prefer Save/Continue; else Cancel/Close; else Escape
const save = dialog.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dialog.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else { await page.keyboard.press('Escape'); }
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// Helper: wait until careerSuggestionsCache has > 0 items
async function waitForSuggestionsCache() {
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: TIME.cache, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
}
// Helper: full reload → wait for cache (no percentage polling)
async function reloadSuggestionsAndWait() {
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await closeAnyOverlay(); // ensure nothing intercepts
await reloadBtn.click();
// If an overlay appears, let it mount (dont require 100%)
const overlayText = page.getByText(/Loading Career Suggestions/i).first();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
// Real readiness check: cache populated
await waitForSuggestionsCache();
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Career Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(
page.getByRole('heading', { name: /Explore Careers - use these tools/i })
).toBeVisible();
// If a priorities/meaning gate is up, close it first
await closeAnyOverlay();
// Ensure suggestions exist: try a reload, or complete inventory if server prompts for it.
const firstTile = page.locator('div.grid button').first();
if (!(await firstTile.isVisible({ timeout: 1500 }).catch(() => false))) {
await reloadSuggestionsAndWait();
// If server demanded Interest Inventory, complete it fast (dev has Randomize), then retry once.
if (sawInventoryAlert) {
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Interest Inventory/i })).toBeVisible();
const randomizeBtn = page.getByRole('button', { name: /Randomize Answers/i });
if (await randomizeBtn.isVisible().catch(() => false)) {
await randomizeBtn.click();
await expect(page.getByText(/60\s*\/\s*60\s*answered/i)).toBeVisible();
} else {
// Fallback: fill each page with Neutral (3)
for (let p = 0; p < 10; p++) {
const selects = page.locator('select');
const n = await selects.count();
for (let i = 0; i < n; i++) await selects.nth(i).selectOption('3');
if (p < 9) await page.getByRole('button', { name: /^Next$/ }).click();
}
}
await page.getByRole('button', { name: /^Submit$/ }).click();
await page.waitForURL('**/career-explorer**', { timeout: 20000 });
await closeAnyOverlay();
await reloadSuggestionsAndWait();
}
}
// Click a suggestion. Capture the title we clicked so we can assert by text later.
let clickedTitle = null;
// Prefer clicking by exact title from cache.
const cachedFirstTitle = await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) && arr.length ? String(arr[0].title || '') : null;
} catch { return null; }
});
if (cachedFirstTitle) {
const esc = cachedFirstTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const tileByTitle = page.getByRole('button', { name: new RegExp(`^${esc}$`) });
await expect(tileByTitle).toBeVisible({ timeout: TIME.tile });
clickedTitle = cachedFirstTitle;
await tileByTitle.click();
} else {
await expect(firstTile).toBeVisible({ timeout: TIME.tile });
clickedTitle = (await firstTile.textContent())?.replace('⚠️', '').trim() || null;
await firstTile.click();
}
// Wait for CareerModal, capture the definitive modal title if present (more reliable than tile text)
const modalH2 = page.getByRole('heading', { level: 2 });
if (await modalH2.isVisible({ timeout: TIME.confirm }).catch(() => false)) {
const t = await modalH2.first().textContent().catch(() => null);
if (t) clickedTitle = t.trim();
}
// Add to Comparison
await expect(page.getByRole('button', { name: /Add to Comparison/i }))
.toBeVisible({ timeout: TIME.confirm });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
// Ratings modal: MUST click Save for the row to appear
{
const overlay = page.locator('div.fixed.inset-0');
if (await overlay.isVisible({ timeout: 2000 }).catch(() => false)) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill any selects (choose '3' if available)
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
if (await sel.isVisible().catch(() => false)) {
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
else {
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
}
}
// Fill any text/number inputs (set to '3' if empty)
const inputs = dlg.locator('input[type="number"], input[type="text"], [role="textbox"]');
const ic = await inputs.count().catch(() => 0);
for (let i = 0; i < ic; i++) {
const inp = inputs.nth(i);
if (await inp.isVisible().catch(() => false)) {
const val = (await inp.inputValue().catch(() => '')) || '';
if (!val) await inp.fill('3');
}
}
// Click Save (cover common label variants), else press Enter
const saveBtn = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done)$/i });
if (await saveBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await saveBtn.click();
} else {
await page.keyboard.press('Enter');
}
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
// Assert success by presence of the clicked title in the table (or duplicate alert fallback).
const table = page.locator('table');
const rowLocator = table.locator('tbody tr');
await table.waitFor({ state: 'attached', timeout: 5000 }).catch(() => {});
await expect(async () => {
// If duplicate alert fired, consider success
if (sawDuplicateAlert) return true;
// Title-based presence (most reliable)
if (clickedTitle) {
const esc = clickedTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const cellWithTitle = table.getByText(new RegExp(esc, 'i'));
if (await cellWithTitle.isVisible().catch(() => false)) return true;
}
// Fallback: any increase in row count
const count = await rowLocator.count().catch(() => 0);
return count > 0;
}).toPass({ timeout: TIME.tableRow });
// ─────────────────────────────────────────────────────────────
// Event bridge checks (no duplicate add to avoid extra rows)
// ─────────────────────────────────────────────────────────────
// Pull a SOC from suggestions cache for events to target
const evSoc = await page.evaluate(() => {
try {
const arr = JSON.parse(localStorage.getItem('careerSuggestionsCache') || '[]');
return Array.isArray(arr) && arr.length ? (arr[0].code || arr[0].soc_code) : null;
} catch { return null; }
});
if (evSoc) {
// 1) open-career should open CareerModal
await page.evaluate((soc) => {
window.dispatchEvent(new CustomEvent('open-career', { detail: { socCode: soc } }));
}, evSoc);
await expect(page.getByRole('button', { name: /Add to Comparison/i }))
.toBeVisible({ timeout: TIME.confirm });
// Close modal to keep state clean
const closeBtnEv = page.getByRole('button', { name: /^Close$/i });
if (await closeBtnEv.isVisible().catch(() => false)) await closeBtnEv.click();
else await page.keyboard.press('Escape');
// 2) add-career should either add (if new) or trigger duplicate alert (if already present)
let sawDupAlertEv = false;
page.once('dialog', async d => {
if (/already in comparison/i.test(d.message())) sawDupAlertEv = true;
await d.accept();
});
const beforeRowsEv = await rowLocator.count().catch(() => 0);
await page.evaluate(({ soc, title }) => {
window.dispatchEvent(new CustomEvent('add-career', {
detail: { socCode: soc, careerName: title || '(name unavailable)' }
}));
}, { soc: evSoc, title: clickedTitle });
// If a ratings modal appears, save neutral defaults
const overlayEv = page.locator('div.fixed.inset-0');
if (await overlayEv.isVisible({ timeout: 1500 }).catch(() => false)) {
const dlg = overlayEv.locator('div[role="dialog"], div.bg-white').first();
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const saveEv = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done|OK)$/i });
if (await saveEv.isVisible({ timeout: 800 }).catch(() => false)) await saveEv.click();
await overlayEv.waitFor({ state: 'hidden', timeout: 4000 }).catch(() => {});
}
await expect(async () => {
const afterEv = await rowLocator.count().catch(() => 0);
// Pass if either duplicate alert fired OR row count increased
return sawDupAlertEv || afterEv > beforeRowsEv;
}).toPass({ timeout: 8000 });
}
});
});

View File

@ -0,0 +1,151 @@
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Career Explorer — Reload Suggestions', () => {
test.setTimeout(40000);
test('clears cache → Reload → cache & tiles repopulated (submit_answers fired)', async ({ page }) => {
const user = loadTestUser();
const TIME = {
overlayAppear: 2000, // overlay should mount quickly
cache: 30000, // allow cold path to populate cache
tile: 8000, // find a tile soon after cache
};
// Track server calls (prove reload hits submit_answers)
let sawSubmitAnswers = false;
page.on('response', (r) => {
if (r.request().method() === 'POST' && r.url().includes('/api/onet/submit_answers')) {
sawSubmitAnswers = true;
}
});
// Helper: close any blocking overlay (priorities/meaning) by saving neutral defaults.
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dialog = overlay.locator('div[role="dialog"], div.bg-white').first();
// Select first non-empty option in each <select> (if any)
const selects = dialog.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
// Fill any text/number inputs with neutral '3'
const inputs = dialog.locator('input[type="number"], input[type="text"], [role="textbox"]');
const ic = await inputs.count().catch(() => 0);
for (let i = 0; i < ic; i++) {
const inp = inputs.nth(i);
if (await inp.isVisible().catch(() => false)) {
const val = (await inp.inputValue().catch(() => '')) || '';
if (!val) await inp.fill('3');
}
}
// Save/Continue; else Cancel/Close; else Escape
const save = dialog.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dialog.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else await page.keyboard.press('Escape');
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// Helper: wait for cache > 0 (source of truth for suggestions readiness)
async function waitForSuggestionsCache() {
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: TIME.cache, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
}
// Helper: complete Interest Inventory quickly if server demands it
async function completeInventoryIfNeeded() {
// If a dialog alert fires (Playwright 'dialog' event), we cannot peek msg here reliably,
// so we proactively check the inventory route if the reload yields no cache.
await page.goto('/interest-inventory', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Interest Inventory/i })).toBeVisible();
const randomizeBtn = page.getByRole('button', { name: /Randomize Answers/i });
if (await randomizeBtn.isVisible().catch(() => false)) {
await randomizeBtn.click();
await expect(page.getByText(/60\s*\/\s*60\s*answered/i)).toBeVisible();
} else {
for (let p = 0; p < 10; p++) {
const selects = page.locator('select');
const n = await selects.count();
for (let i = 0; i < n; i++) await selects.nth(i).selectOption('3');
if (p < 9) await page.getByRole('button', { name: /^Next$/ }).click();
}
}
await page.getByRole('button', { name: /^Submit$/ }).click();
await page.waitForURL('**/career-explorer**', { timeout: 20000 });
await closeAnyOverlay();
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Career Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
await closeAnyOverlay();
// 1) Clear cache and reload page → grid should have no tiles
await page.evaluate(() => localStorage.removeItem('careerSuggestionsCache'));
await page.reload({ waitUntil: 'networkidle' });
await closeAnyOverlay();
const tile = page.locator('div.grid button').first();
// Short check: no tile should be visible yet
const tileVisiblePre = await tile.isVisible({ timeout: 1000 }).catch(() => false);
expect(tileVisiblePre).toBeFalsy();
// 2) Click Reload Career Suggestions
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
// If overlay mounts, let it mount; we don't require 100% display
const overlayText = page.getByText(/Loading Career Suggestions/i).first();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
// 3) Wait for suggestions cache
try {
await waitForSuggestionsCache();
} catch {
// If cache didn't populate, complete inventory once and retry reload
await completeInventoryIfNeeded();
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
await waitForSuggestionsCache();
}
// 4) Tiles should now be present
await expect(tile).toBeVisible({ timeout: TIME.tile });
// 5) Assert submit_answers POST fired during reload
expect(sawSubmitAnswers).toBeTruthy();
});
});

View File

@ -0,0 +1,111 @@
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 Career Explorer — CareerSearch datalist', () => {
test.setTimeout(20000);
test('datalist commit opens modal; Change resets input', async ({ page }) => {
const user = loadTestUser();
// Helpers
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// If modal asks for ratings, pick neutral values and Save/Continue so it goes away.
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
if (await sel.isVisible().catch(() => false)) {
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
else {
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
}
}
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const save = dlg.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dlg.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else await page.keyboard.press('Escape');
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Go to Career Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
await closeAnyOverlay();
const input = page.getByPlaceholder('Start typing a career...');
// If previously selected, the input is disabled; click Change to reset.
if (await input.isDisabled()) {
const changeLink = page.getByRole('button', { name: /^Change$/i }).or(page.getByText(/^Change$/));
await expect(changeLink).toBeVisible({ timeout: 5000 });
await changeLink.click();
await expect(input).toBeEnabled({ timeout: 2000 });
await expect(input).toHaveValue('');
}
// Type a partial and wait for datalist options to populate
await input.fill('block');
const options = page.locator('datalist#career-titles option');
await expect
.poll(async () => await options.count(), { timeout: 7000, message: 'no datalist options' })
.toBeGreaterThan(0);
// Take the first suggestion's exact value (e.g., "Blockchain Engineers")
const firstValue = await options.first().evaluate(el => el.value);
// Commit by setting exact value + blur (component commits on exact + blur)
await input.fill(firstValue);
await input.blur();
// Loading overlay may show; wait for it to appear (optional) then hide
const loading = page.getByText(/Loading Career/i).first(); // matches “Loading Career Suggestions…”
await loading.isVisible({ timeout: 2000 }).catch(() => {});
await loading.waitFor({ state: 'hidden', timeout: 60000 }).catch(() => {}); // guard slow cold path
// CareerModal should open (Add to Comparison button present)
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: 15000 });
// Close the modal (dont add in this test)
const closeBtn = page.getByRole('button', { name: /^Close$/i });
if (await closeBtn.isVisible().catch(() => false)) {
await closeBtn.click();
} else {
await page.keyboard.press('Escape');
}
// Input becomes disabled after selection; click Change to reset
await expect(input).toBeDisabled();
const changeLink = page.getByRole('button', { name: /^Change$/i }).or(page.getByText(/^Change$/));
await expect(changeLink).toBeVisible({ timeout: 5000 });
await changeLink.click();
// Now the input should be enabled and cleared
await expect(input).toBeEnabled();
await expect(input).toHaveValue('');
});
});

View File

@ -0,0 +1,178 @@
// tests/e2e/07-education-handoff.spec.mjs
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p1 Education/Skills handoff', () => {
test.setTimeout(20000);
test('add to comparison → Plan your Education/Skills → navigates with selectedCareer stored', async ({ page }) => {
const user = loadTestUser();
const TIME = {
overlayAppear: 2000,
cache: 60000,
tile: 8000,
confirm: 7000,
route: 20000,
};
// Helpers
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill selects with neutral '3' (or first non-empty)
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
else {
const opts = sel.locator('option');
const n = await opts.count().catch(() => 0);
for (let j = 0; j < n; j++) {
const v = await opts.nth(j).getAttribute('value');
if (v) { await sel.selectOption(v); break; }
}
}
}
// Fill textbox if present
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const save = dlg.getByRole('button', { name: /(Save|Continue|Done|OK)/i });
const cancel = dlg.getByRole('button', { name: /(Cancel|Close)/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else if (await cancel.isVisible({ timeout: 500 }).catch(() => false)) await cancel.click();
else await page.keyboard.press('Escape');
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
async function waitForSuggestionsCache() {
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: TIME.cache, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
}
async function ensureSuggestions() {
const firstTile = page.locator('div.grid button').first();
if (await firstTile.isVisible({ timeout: 1500 }).catch(() => false)) return;
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await closeAnyOverlay();
await reloadBtn.click();
// Let overlay mount if it appears (we don't require 100%)
const overlayText = page.getByText(/Loading Career Suggestions/i).first();
await overlayText.isVisible({ timeout: TIME.overlayAppear }).catch(() => {});
await waitForSuggestionsCache();
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
await closeAnyOverlay();
// Make sure we have suggestions
await ensureSuggestions();
// Open a suggestion tile → CareerModal
const firstTile = page.locator('div.grid button').first();
await expect(firstTile).toBeVisible({ timeout: TIME.tile });
let clickedTitle = (await firstTile.textContent())?.replace('⚠️', '').trim() || null;
await firstTile.click();
// Prefer modal H2 for authoritative title
const modalH2 = page.getByRole('heading', { level: 2 });
if (await modalH2.isVisible({ timeout: TIME.confirm }).catch(() => false)) {
const t = await modalH2.first().textContent().catch(() => null);
if (t) clickedTitle = t.trim();
}
// Add to Comparison
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: TIME.confirm });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
// Ratings modal → Save (must persist the row)
{
const overlay = page.locator('div.fixed.inset-0');
if (await overlay.isVisible({ timeout: 2000 }).catch(() => false)) {
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill selects to '3' if present
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
// Fill textbox if present
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const saveBtn = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done)$/i });
if (await saveBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
await saveBtn.click();
} else {
await page.keyboard.press('Enter');
}
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
}
// Find the row for clickedTitle (fallback: first row)
const table = page.locator('table');
await table.waitFor({ state: 'attached', timeout: 5000 }).catch(() => {});
let targetRow;
if (clickedTitle) {
const cell = table.getByText(new RegExp(clickedTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
if (await cell.isVisible().catch(() => false)) {
targetRow = cell.locator('xpath=ancestor::tr').first();
}
}
if (!targetRow) {
targetRow = table.locator('tbody tr').first();
}
// Click “Plan your Education/Skills” with confirm
page.once('dialog', d => d.accept());
await targetRow.getByRole('button', { name: /Plan your Education\/Skills/i }).click();
// Route change
await page.waitForURL('**/educational-programs**', { timeout: TIME.route });
// Assert selectedCareer is stored with expected shape
const selected = await page.evaluate(() => {
try {
return JSON.parse(localStorage.getItem('selectedCareer') || 'null');
} catch { return null; }
});
expect(selected).toBeTruthy();
expect(typeof selected.soc_code).toBe('string');
expect(Array.isArray(selected.cip_code)).toBeTruthy();
});
});

View File

@ -0,0 +1,28 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Logout + guard', () => {
test.setTimeout(10000);
test('logout clears session; protected routes redirect to /signin', async ({ page }) => {
const user = loadTestUser();
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Click Logout in top nav (link or button)
const logout = page.getByRole('link', { name: /Logout/i }).or(page.getByRole('button', { name: /Logout/i }));
await expect(logout).toBeVisible({ timeout: 5000 });
await logout.click();
// Hitting a protected route should bounce to /signin
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page).toHaveURL(/\/signin(\?|$)/, { timeout: 10000 });
});
});

View File

@ -0,0 +1,122 @@
// @ts-check
import { test, expect } from '@playwright/test';
import { loadTestUser } from '../utils/testUser.js';
test.describe('@p0 Comparison: add → duplicate blocked → remove → persists', () => {
test.setTimeout(15000);
test('add one, block duplicate, remove and persist', async ({ page }) => {
const user = loadTestUser();
// Helpers
async function closeAnyOverlay() {
const overlay = page.locator('div.fixed.inset-0');
if (!(await overlay.isVisible({ timeout: 500 }).catch(() => false))) return;
const dlg = overlay.locator('div[role="dialog"], div.bg-white').first();
// Fill selects (neutral '3') if present
const selects = dlg.locator('select');
const sc = await selects.count().catch(() => 0);
for (let i = 0; i < sc; i++) {
const sel = selects.nth(i);
const has3 = await sel.locator('option[value="3"]').count().catch(() => 0);
if (has3) await sel.selectOption('3');
}
// Fill textbox if present
const tb = dlg.locator('input, textarea, [role="textbox"]').first();
if (await tb.isVisible().catch(() => false)) await tb.fill('3');
const save = dlg.getByRole('button', { name: /^(Save|Save Ratings|Confirm|Done|OK)$/i });
if (await save.isVisible({ timeout: 500 }).catch(() => false)) await save.click();
else await page.keyboard.press('Enter');
await overlay.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
}
async function ensureSuggestions() {
const firstTile = page.locator('div.grid button').first();
if (await firstTile.isVisible({ timeout: 1500 }).catch(() => false)) return;
const reloadBtn = page.getByRole('button', { name: /Reload Career Suggestions/i });
await expect(reloadBtn).toBeVisible();
await reloadBtn.click();
// Wait for cache
await expect
.poll(async () => {
return await page.evaluate(() => {
try {
const s = localStorage.getItem('careerSuggestionsCache');
const arr = s ? JSON.parse(s) : [];
return Array.isArray(arr) ? arr.length : 0;
} catch { return 0; }
});
}, { timeout: 60000, message: 'careerSuggestionsCache not populated' })
.toBeGreaterThan(0);
await expect(firstTile).toBeVisible({ timeout: 10000 });
}
// Sign in
await page.context().clearCookies();
await page.goto('/signin', { waitUntil: 'networkidle' });
await page.getByPlaceholder('Username', { exact: true }).fill(user.username);
await page.getByPlaceholder('Password', { exact: true }).fill(user.password);
await page.getByRole('button', { name: /^Sign In$/ }).click();
await page.waitForURL('**/signin-landing**', { timeout: 15000 });
// Explorer
await page.goto('/career-explorer', { waitUntil: 'networkidle' });
await expect(page.getByRole('heading', { name: /Explore Careers - use these tools/i })).toBeVisible();
await closeAnyOverlay();
// Ensure at least one tile is present
await ensureSuggestions();
// Open first suggestion -> modal
const firstTile = page.locator('div.grid button').first();
await expect(firstTile).toBeVisible({ timeout: 8000 });
const tileTitle = (await firstTile.textContent())?.replace('⚠️', '').trim() || null;
await firstTile.click();
// Add to Comparison (then ratings modal Save)
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
await closeAnyOverlay();
// Table row must exist for that title (fallback: any row exists)
const table = page.locator('table');
await table.waitFor({ state: 'attached', timeout: 8000 }).catch(() => {});
let row = null;
if (tileTitle) {
const cell = table.getByText(new RegExp(tileTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')).first();
if (await cell.isVisible().catch(() => false)) row = cell.locator('xpath=ancestor::tr').first();
}
if (!row) row = table.locator('tbody tr').first();
await expect(row).toBeVisible({ timeout: 8000 });
// Try to add the same career again → expect duplicate alert
let sawDuplicate = false;
page.once('dialog', async d => {
if (/already in comparison/i.test(d.message())) sawDuplicate = true;
await d.accept();
});
// Open the same modal again (either by clicking the same tile or by search commit)
if (tileTitle) await firstTile.click();
else await page.locator('div.grid button').first().click();
await expect(page.getByRole('button', { name: /Add to Comparison/i })).toBeVisible({ timeout: 15000 });
await page.getByRole('button', { name: /Add to Comparison/i }).click();
await closeAnyOverlay();
expect(sawDuplicate).toBeTruthy();
// Remove the row
await row.getByRole('button', { name: /^Remove$/ }).click();
// Row should disappear
await expect(row).toBeHidden({ timeout: 8000 });
// Reload page → row should still be gone (persisted)
await page.reload({ waitUntil: 'networkidle' });
await table.waitFor({ state: 'attached', timeout: 8000 }).catch(() => {});
if (tileTitle) {
const cellAgain = table.getByText(new RegExp(tileTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'));
expect(await cellAgain.isVisible().catch(() => false)).toBeFalsy();
}
});
});

Some files were not shown because too many files have changed in this diff Show More