Compare commits
No commits in common. "666427a7c958d986ef4a1c8cea336101749f4fd9" and "a736e1d4d1e7a8e750d2e7c9f3bf9d0ccab62c6c" have entirely different histories.
666427a7c9
...
a736e1d4d1
@ -1 +1 @@
|
||||
b5aad6117f63426726be6ae9a07e5aaa938f14ff-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
fb83dd6424562765662889aea6436fdb4b1b975f-22db19df6f582b90837f001b7bf6ead59acca441-e9eccd451b778829eb2f2c9752c670b707e1268b
|
||||
|
||||
@ -30,10 +30,3 @@ coverage
|
||||
*.crt
|
||||
*.pfx
|
||||
|
||||
# Test sources & artifacts
|
||||
tests/
|
||||
playwright-report/
|
||||
test-results/
|
||||
blob-report/
|
||||
*.trace.zip
|
||||
|
||||
|
||||
8
.env
Normal file
@ -0,0 +1,8 @@
|
||||
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
@ -26,4 +26,3 @@ uploads/.env
|
||||
.env
|
||||
.env.*
|
||||
scan-env.sh
|
||||
.aptiva-test-user.json
|
||||
|
||||
@ -1 +1 @@
|
||||
98f674eca26e366aee0b41f250978982060105f0
|
||||
1a7fe9191922c4f8389027ed53b6a4909740a48b
|
||||
|
||||
@ -1 +1 @@
|
||||
98f674eca26e366aee0b41f250978982060105f0
|
||||
1a7fe9191922c4f8389027ed53b6a4909740a48b
|
||||
|
||||
@ -16,7 +16,6 @@ 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 (
|
||||
@ -508,28 +507,6 @@ 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 {
|
||||
@ -729,133 +706,6 @@ 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)
|
||||
@ -937,41 +787,78 @@ 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' });
|
||||
}
|
||||
|
||||
// Only fetch what you need to verify creds
|
||||
// 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.
|
||||
const query = `
|
||||
SELECT ua.user_id AS userProfileId, ua.hashed_password
|
||||
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
|
||||
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 { userProfileId, hashed_password } = results[0];
|
||||
const isMatch = await bcrypt.compare(password, hashed_password);
|
||||
const row = results[0];
|
||||
|
||||
// Compare password with bcrypt
|
||||
const isMatch = await bcrypt.compare(password, row.hashed_password);
|
||||
if (!isMatch) {
|
||||
return res.status(401).json({ error: 'Invalid username or password' });
|
||||
}
|
||||
|
||||
// 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());
|
||||
// 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 {}
|
||||
}
|
||||
|
||||
return res.status(200).json({ message: 'Login successful' });
|
||||
|
||||
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' });
|
||||
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
|
||||
|
||||
res.status(200).json({
|
||||
message: 'Login successful',
|
||||
token,
|
||||
id: row.userProfileId,
|
||||
user: profile
|
||||
});
|
||||
} catch (err) {
|
||||
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 });
|
||||
@ -1144,72 +1031,29 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
FETCH USER PROFILE (MySQL) — safe, minimal, no id
|
||||
------------------------------------------------------------------ */
|
||||
FETCH USER PROFILE (MySQL)
|
||||
------------------------------------------------------------------ */
|
||||
app.get('/api/user-profile', requireAuth, async (req, res) => {
|
||||
const profileId = req.userId;
|
||||
const profileId = req.userId; // from requireAuth middleware
|
||||
|
||||
try {
|
||||
// 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 [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' });
|
||||
}
|
||||
|
||||
// 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);
|
||||
const row = results[0];
|
||||
if (row?.email) {
|
||||
try { row.email = decrypt(row.email); } catch {}
|
||||
}
|
||||
res.status(200).json(row);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error fetching user profile:', err?.message || err);
|
||||
return res.status(500).json({ error: 'Internal server error' });
|
||||
console.error('Error fetching user profile:', err.message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
SALARY_INFO REMAINS IN SQLITE
|
||||
------------------------------------------------------------------ */
|
||||
|
||||
@ -60,6 +60,9 @@ 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
|
||||
@ -196,7 +199,6 @@ 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();
|
||||
|
||||
@ -328,21 +330,18 @@ 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) => {
|
||||
// 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 MAX_ARRAY = 20; // sane cap; adjust if you truly need more
|
||||
|
||||
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];
|
||||
if (obj[k].length === 1) obj[k] = obj[k][0]; // collapse singletons
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -354,7 +353,6 @@ 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' });
|
||||
}
|
||||
@ -381,16 +379,6 @@ 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 = {
|
||||
@ -495,10 +483,6 @@ 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() {
|
||||
@ -509,30 +493,6 @@ 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,
|
||||
@ -603,72 +563,16 @@ app.use((req, res, next) => next());
|
||||
|
||||
// ──────────────────────────────── Data endpoints ───────────────────────────────
|
||||
|
||||
/**************************************************
|
||||
* 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/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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// /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 = [
|
||||
@ -717,13 +621,6 @@ 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
|
||||
@ -739,25 +636,6 @@ 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(
|
||||
@ -767,96 +645,6 @@ 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,
|
||||
@ -1182,11 +970,18 @@ app.get('/api/onet/career-description/:socCode', async (req, res) => {
|
||||
// CIP route
|
||||
app.get('/api/cip/:socCode', (req, res) => {
|
||||
const { socCode } = req.params;
|
||||
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' });
|
||||
});
|
||||
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' });
|
||||
});
|
||||
|
||||
/** @aiTool {
|
||||
"name": "getSchoolsForCIPs",
|
||||
@ -1347,19 +1142,60 @@ 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;
|
||||
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 });
|
||||
});
|
||||
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.' });
|
||||
}
|
||||
|
||||
// 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",
|
||||
@ -1381,51 +1217,49 @@ app.get('/api/tuition', (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 {
|
||||
const keyArea = String(area || ''); // allow empty → national only
|
||||
const cacheKey = `${socCode}|${keyArea}`;
|
||||
const cached = SALARY_CACHE.get(cacheKey);
|
||||
if (cached) return res.json(cached);
|
||||
let regionalRow = null;
|
||||
let nationalRow = null;
|
||||
|
||||
// Bind regional placeholders (five times) + occ + area
|
||||
const row = await SALARY_STMT.get(
|
||||
keyArea, keyArea, keyArea, keyArea, keyArea, socCode, keyArea
|
||||
);
|
||||
if (area) {
|
||||
regionalRow = await dbSqlite.get(regionalQuery, [socCode, area]);
|
||||
}
|
||||
nationalRow = await dbSqlite.get(nationalQuery, [socCode]);
|
||||
|
||||
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)
|
||||
) {
|
||||
if (!regionalRow && !nationalRow) {
|
||||
console.log('No salary data found for:', { socCode, area });
|
||||
return res.status(404).json({ error: 'No salary data found' });
|
||||
}
|
||||
|
||||
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);
|
||||
const salaryData = {
|
||||
regional: regionalRow || {},
|
||||
national: nationalRow || {},
|
||||
};
|
||||
console.log('Salary data retrieved:', salaryData);
|
||||
res.json(salaryData);
|
||||
} catch (error) {
|
||||
console.error('Error executing salary query:', error.message);
|
||||
res.status(500).json({ error: 'Failed to fetch salary data' });
|
||||
@ -1542,6 +1376,27 @@ 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
|
||||
****************************************************/
|
||||
@ -1643,25 +1498,9 @@ app.post(
|
||||
accountEmail = row?.email || null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// If still missing, fetch from server1 using the caller's session
|
||||
if (!accountEmail) {
|
||||
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 */ }
|
||||
accountEmail = (req.body && req.body.email) || null;
|
||||
}
|
||||
|
||||
if (!accountEmail) {
|
||||
return res.status(400).json({ error: 'No email on file for this user' });
|
||||
}
|
||||
|
||||
@ -47,7 +47,11 @@ const API_BASE = `${INTERNAL_SELF_BASE}/api`;
|
||||
const DATA_DIR = path.join(__dirname, 'data');
|
||||
|
||||
/* ─── helper: canonical public origin ─────────────────────────── */
|
||||
const PUBLIC_BASE = (process.env.APTIVA_API_BASE || '').replace(/\/+$/, '');
|
||||
const PUBLIC_BASE = (
|
||||
process.env.APTIVA_AI_BASE
|
||||
|| process.env.REACT_APP_API_URL
|
||||
|| ''
|
||||
).replace(/\/+$/, '');
|
||||
|
||||
const ALLOWED_REDIRECT_HOSTS = new Set([
|
||||
new URL(PUBLIC_BASE || 'http://localhost').host
|
||||
@ -56,7 +60,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 (server3:5002)
|
||||
'server3', // self-calls (localhost:5002)
|
||||
'api.openai.com', // OpenAI SDK traffic
|
||||
'api.stripe.com', // Stripe SDK traffic
|
||||
'api.twilio.com' // smsService may hit Twilio from this proc
|
||||
@ -89,10 +93,9 @@ 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'];
|
||||
@ -504,34 +507,6 @@ 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,
|
||||
@ -580,9 +555,34 @@ 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,73 +598,32 @@ 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 ? 1 : 0, pro ? 1 : 0, h]
|
||||
SET is_premium = ?, is_pro_premium = ?
|
||||
WHERE stripe_customer_id_hash = ?`,
|
||||
[premium, pro, h]
|
||||
);
|
||||
};
|
||||
|
||||
// Recompute flags from Stripe (source of truth)
|
||||
const recomputeFlagsFromStripe = async (customerId) => {
|
||||
const subs = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100
|
||||
});
|
||||
// Consider only “active-like” states
|
||||
const ACTIVE_STATES = new Set(['active']);
|
||||
let hasPremium = false;
|
||||
let hasPro = false;
|
||||
// after computing hasPremium/hasPro
|
||||
const activeCount = subs.data.filter(s => ACTIVE_STATES.has(s.status)).length;
|
||||
if (activeCount > 1) {
|
||||
console.warn('[Stripe] multiple active subs for customer', { customerId, activeCount });
|
||||
}
|
||||
|
||||
for (const s of subs.data) {
|
||||
if (!ACTIVE_STATES.has(s.status)) continue;
|
||||
for (const item of s.items.data) {
|
||||
const pid = item.price.id;
|
||||
if (pid === process.env.STRIPE_PRICE_PRO_MONTH || pid === process.env.STRIPE_PRICE_PRO_YEAR) {
|
||||
hasPro = true;
|
||||
}
|
||||
if (pid === process.env.STRIPE_PRICE_PREMIUM_MONTH || pid === process.env.STRIPE_PRICE_PREMIUM_YEAR) {
|
||||
hasPremium = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If any Pro sub exists, Pro wins; otherwise Premium if any premium exists
|
||||
// Pro implies premium access; premium only if no pro
|
||||
const finalIsPro = hasPro ? 1 : 0;
|
||||
const finalIsPremium = hasPro ? 1 : (hasPremium ? 1 : 0);
|
||||
await upFlags(customerId, finalIsPremium, finalIsPro);
|
||||
};
|
||||
|
||||
switch (event.type) {
|
||||
case 'customer.subscription.created':
|
||||
case 'customer.subscription.updated':
|
||||
case 'customer.subscription.deleted': {
|
||||
const sub = event.data.object;
|
||||
await recomputeFlagsFromStripe(sub.customer);
|
||||
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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
case 'customer.subscription.deleted': {
|
||||
const sub = event.data.object;
|
||||
await upFlags(sub.customer, 0, 0);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@ -674,153 +633,11 @@ 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');
|
||||
@ -867,6 +684,10 @@ function authenticatePremiumUser(req, res, next) {
|
||||
}
|
||||
};
|
||||
|
||||
/** ------------------------------------------------------------------
|
||||
* Returns the user’s stripe_customer_id (or null) given req.id.
|
||||
* Creates a new Stripe Customer & saves it if missing.
|
||||
* ----------------------------------------------------------------- */
|
||||
|
||||
/** ------------------------------------------------------------------
|
||||
* Returns the user’s Stripe customer‑id (decrypted) given req.id.
|
||||
@ -905,21 +726,6 @@ 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 = {
|
||||
@ -1050,16 +856,17 @@ 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 [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 || {});
|
||||
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] || {});
|
||||
} catch (error) {
|
||||
console.error('Error fetching latest career profile:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch latest career profile' });
|
||||
@ -1071,12 +878,8 @@ app.get('/api/premium/career-profile/all', authenticatePremiumUser, async (req,
|
||||
try {
|
||||
const sql = `
|
||||
SELECT
|
||||
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
|
||||
*,
|
||||
start_date AS start_date
|
||||
FROM career_profiles
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date ASC
|
||||
@ -1107,9 +910,7 @@ app.get('/api/premium/career-profile/:careerProfileId', authenticatePremiumUser,
|
||||
if (!rows[0]) {
|
||||
return res.status(404).json({ error: 'Career profile not found or not yours.' });
|
||||
}
|
||||
const row = { ...rows[0] };
|
||||
delete row.user_id; // do not ship user_id
|
||||
return res.json(row);
|
||||
res.json(rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching single career profile:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch career profile by ID.' });
|
||||
@ -2800,32 +2601,33 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
||||
|
||||
const id = uuidv4();
|
||||
await pool.query(`
|
||||
INSERT INTO milestones (
|
||||
INSERT INTO milestones (
|
||||
id,
|
||||
user_id,
|
||||
career_profile_id,
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
progress,
|
||||
status,
|
||||
is_universal
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id,
|
||||
user_id,
|
||||
req.id,
|
||||
career_profile_id,
|
||||
title,
|
||||
description,
|
||||
description || '',
|
||||
date,
|
||||
progress,
|
||||
status,
|
||||
is_universal
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id,
|
||||
req.id,
|
||||
career_profile_id,
|
||||
title,
|
||||
description || '',
|
||||
date,
|
||||
progress || 0,
|
||||
status || 'planned',
|
||||
is_universal ? 1 : 0
|
||||
]);
|
||||
progress || 0,
|
||||
status || 'planned',
|
||||
is_universal ? 1 : 0
|
||||
]);
|
||||
|
||||
createdMilestones.push({
|
||||
id,
|
||||
user_id: req.id,
|
||||
career_profile_id,
|
||||
title,
|
||||
description: description || '',
|
||||
@ -2895,17 +2697,7 @@ app.post('/api/premium/milestone', authenticatePremiumUser, async (req, res) =>
|
||||
is_universal: is_universal ? 1 : 0,
|
||||
tasks: []
|
||||
};
|
||||
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: []
|
||||
});
|
||||
return res.status(201).json(newMilestone);
|
||||
} catch (err) {
|
||||
console.error('Error creating milestone(s):', err);
|
||||
res.status(500).json({ error: 'Failed to create milestone(s).' });
|
||||
@ -2985,9 +2777,9 @@ app.put('/api/premium/milestones/:milestoneId', authenticatePremiumUser, async (
|
||||
`, [milestoneId]);
|
||||
|
||||
res.json({
|
||||
...safeMilestoneRow(updatedMilestoneRow),
|
||||
tasks: (tasks || []).map(safeTaskRow)
|
||||
});
|
||||
...updatedMilestoneRow,
|
||||
tasks: tasks || []
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error updating milestone:', err);
|
||||
res.status(500).json({ error: 'Failed to update milestone.' });
|
||||
@ -3019,13 +2811,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(safeTaskRow(t));
|
||||
acc[t.milestone_id].push(t);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const uniMils = universalRows.map(m => ({
|
||||
...safeMilestoneRow(m),
|
||||
const uniMils = universalRows.map(m => ({
|
||||
...m,
|
||||
tasks: tasksByMilestone[m.id] || []
|
||||
}));
|
||||
return res.json({ milestones: uniMils });
|
||||
@ -3051,13 +2843,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(safeTaskRow(t));
|
||||
acc[t.milestone_id].push(t);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const milestonesWithTasks = milestones.map(m => ({
|
||||
...safeMilestoneRow(m),
|
||||
...m,
|
||||
tasks: tasksByMilestone[m.id] || []
|
||||
}));
|
||||
res.json({ milestones: milestonesWithTasks });
|
||||
@ -3321,24 +3113,11 @@ 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
|
||||
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`,
|
||||
'SELECT * 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,
|
||||
@ -3352,20 +3131,7 @@ app.get('/api/premium/financial-profile', authenticatePremiumUser, async (req, r
|
||||
extra_cash_retirement_pct: 50
|
||||
});
|
||||
}
|
||||
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)
|
||||
});
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error('financial‑profile GET error:', err);
|
||||
res.status(500).json({ error: 'DB error' });
|
||||
@ -3603,20 +3369,17 @@ 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' });
|
||||
const row = { ...rows[0] };
|
||||
delete row.user_id; // 🚫 do not ship user_id
|
||||
return res.json(row);
|
||||
res.json(rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error fetching college profile:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch college profile.' });
|
||||
@ -3624,115 +3387,32 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res
|
||||
});
|
||||
|
||||
// GET every college profile for the logged‑in user
|
||||
app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req, res) => {
|
||||
app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req,res)=>{
|
||||
const sql = `
|
||||
SELECT
|
||||
cp.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
|
||||
SELECT cp.*,
|
||||
DATE_FORMAT(cp.created_at,'%Y-%m-%d') AS created_at,
|
||||
IFNULL(cpr.scenario_title, cpr.career_name) AS career_title
|
||||
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]);
|
||||
|
||||
// Whitelist shape + decrypt selected strings (no ids beyond career_profile_id)
|
||||
const safe = rows.map(r => {
|
||||
const out = { ...r };
|
||||
const [rows] = await pool.query(sql, [req.id]);
|
||||
const decrypted = rows.map(r => {
|
||||
const row = { ...r };
|
||||
for (const k of ['career_title', 'selected_school', 'selected_program']) {
|
||||
const v = out[k];
|
||||
const v = row[k];
|
||||
if (typeof v === 'string' && v.startsWith('gcm:')) {
|
||||
try { out[k] = decrypt(v); } catch { /* best-effort */ }
|
||||
try { row[k] = decrypt(v); } catch {} // best-effort
|
||||
}
|
||||
}
|
||||
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
|
||||
};
|
||||
return row;
|
||||
});
|
||||
|
||||
return res.json({ collegeProfiles: safe });
|
||||
res.json({ collegeProfiles: decrypted });
|
||||
});
|
||||
|
||||
|
||||
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
|
||||
------------------------------------------------------------------ */
|
||||
@ -3989,6 +3669,7 @@ 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,
|
||||
@ -4052,11 +3733,11 @@ app.put('/api/premium/tasks/:taskId', authenticatePremiumUser, async (req, res)
|
||||
]);
|
||||
|
||||
const [[updatedTask]] = await pool.query(`
|
||||
SELECT id, milestone_id, title, description, due_date, status, created_at, updated_at
|
||||
FROM tasks
|
||||
WHERE id = ?
|
||||
`, [taskId]);
|
||||
res.json(updatedTask);
|
||||
SELECT *
|
||||
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.' });
|
||||
@ -4824,9 +4505,6 @@ 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) => {
|
||||
@ -4845,34 +4523,15 @@ 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;
|
||||
|
||||
// 👇 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',
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode : 'subscription',
|
||||
customer : customerId,
|
||||
line_items : [{ price: priceId, quantity: 1 }],
|
||||
allow_promotion_codes : false,
|
||||
success_url : `${safeSuccess}`,
|
||||
cancel_url : `${safeCancel}`
|
||||
}, {
|
||||
// reduce duplicate creation on rapid retries
|
||||
idempotencyKey: `sub:${req.id}:${priceId}`
|
||||
allow_promotion_codes : true,
|
||||
success_url : safeSuccess,
|
||||
cancel_url : safeCancel
|
||||
});
|
||||
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);
|
||||
@ -4889,7 +4548,8 @@ 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?ck=portal`;
|
||||
const safeReturn = return_url && isSafeRedirect(return_url) ? return_url : `${base}/billing`;
|
||||
|
||||
const cid = await getOrCreateStripeCustomerId(req);
|
||||
|
||||
const portal = await stripe.billingPortal.sessions.create({
|
||||
|
||||
@ -1,44 +1,42 @@
|
||||
// backend/shared/requireAuth.js
|
||||
// shared/auth/requireAuth.js
|
||||
import jwt from 'jsonwebtoken';
|
||||
import pool from '../config/mysqlPool.js';
|
||||
|
||||
function readSessionCookie(req, cookieName) {
|
||||
if (req.cookies && req.cookies[cookieName]) return req.cookies[cookieName];
|
||||
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
|
||||
const raw = req.headers.cookie || '';
|
||||
for (const part of raw.split(';')) {
|
||||
const [k, ...rest] = part.trim().split('=');
|
||||
if (k === cookieName) return decodeURIComponent(rest.join('='));
|
||||
if (k === COOKIE_NAME) 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 {
|
||||
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
|
||||
// 1) Try Bearer (legacy) then cookie (current)
|
||||
const authz = req.headers.authorization || '';
|
||||
const token = authz.startsWith('Bearer ')
|
||||
? authz.slice(7)
|
||||
: readSessionCookie(req, COOKIE_NAME);
|
||||
let token =
|
||||
authz.startsWith('Bearer ')
|
||||
? authz.slice(7)
|
||||
: readSessionCookie(req);
|
||||
|
||||
if (!token) return res.status(401).json({ error: 'Auth required' });
|
||||
|
||||
// 2) Verify
|
||||
// 2) Verify JWT
|
||||
let payload;
|
||||
try { payload = jwt.verify(token, JWT_SECRET); }
|
||||
catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
|
||||
@ -46,30 +44,27 @@ export async function requireAuth(req, res, next) {
|
||||
const userId = payload.id;
|
||||
const iatMs = (payload.iat || 0) * 1000;
|
||||
|
||||
// 3) Absolute max token age
|
||||
// 3) Absolute max token age (optional)
|
||||
if (MAX_AGE && Date.now() - iatMs > MAX_AGE) {
|
||||
return res.status(401).json({ error: 'Session expired. Please sign in again.' });
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
// 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;
|
||||
|
||||
if (changedAtMs && iatMs < changedAtMs) {
|
||||
return res.status(401).json({ error: 'Session invalidated. Please sign in again.' });
|
||||
}
|
||||
|
||||
req.userId = userId;
|
||||
return next();
|
||||
next();
|
||||
} catch (e) {
|
||||
console.error('[requireAuth]', e?.message || e);
|
||||
return res.status(500).json({ error: 'Server error' });
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
|
||||
# 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 they’re current and in scope.
|
||||
@ -1,126 +0,0 @@
|
||||
// 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);
|
||||
});
|
||||
@ -1,24 +0,0 @@
|
||||
|
||||
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'),
|
||||
]);
|
||||
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
// 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); });
|
||||
@ -1,174 +0,0 @@
|
||||
|
||||
// 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);
|
||||
});
|
||||
@ -50,9 +50,6 @@ 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:
|
||||
@ -136,7 +133,6 @@ 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}
|
||||
|
||||
@ -248,7 +248,3 @@ 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;
|
||||
|
||||
106
nginx.conf
@ -1,14 +1,11 @@
|
||||
worker_rlimit_nofile 131072;
|
||||
events { worker_connections 16384;
|
||||
}
|
||||
events {}
|
||||
|
||||
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=100r/s;
|
||||
limit_req_zone $binary_remote_addr zone=reqperip:10m rate=10r/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;
|
||||
@ -16,8 +13,7 @@ http {
|
||||
|
||||
# ───────────── upstreams to Docker services ─────────────
|
||||
upstream backend5000 { server server1:5000; } # auth & free
|
||||
upstream backend5001 { server server2:5001;
|
||||
keepalive 1024;} # onet, distance, etc.
|
||||
upstream backend5001 { server server2:5001; } # 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; }
|
||||
@ -37,17 +33,14 @@ http {
|
||||
########################################################################
|
||||
server {
|
||||
listen 443 ssl;
|
||||
http2 on;
|
||||
http2_max_concurrent_streams 2048;
|
||||
http2_idle_timeout 90s;
|
||||
http2_recv_timeout 90s;
|
||||
http2 on; # modern syntax
|
||||
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 ====
|
||||
# ==== RUNTIME PROTECTIONS (dev test) ====
|
||||
server_tokens off;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
@ -70,6 +63,12 @@ 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; }
|
||||
@ -90,92 +89,25 @@ http {
|
||||
}
|
||||
|
||||
# ───── API reverse‑proxy rules ─────
|
||||
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/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/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 {
|
||||
limit_conn perip 5;
|
||||
limit_req zone=reqperip burst=10 nodelay;
|
||||
proxy_pass http://backend5001;
|
||||
}
|
||||
location ^~ /api/support { 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/ {
|
||||
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/premium/ { proxy_pass http://backend5002; }
|
||||
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
@ -70,7 +70,6 @@
|
||||
"@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",
|
||||
@ -3329,22 +3328,6 @@
|
||||
"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",
|
||||
@ -15492,53 +15475,6 @@
|
||||
"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",
|
||||
|
||||
@ -104,7 +104,6 @@
|
||||
"@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",
|
||||
|
||||
6
playwright.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
module.exports = defineConfig({
|
||||
testDir: 'tests',
|
||||
projects:[ {name:'chromium', use:{browserName:'chromium'}} ],
|
||||
timeout: 30000,
|
||||
});
|
||||
@ -1,27 +0,0 @@
|
||||
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'] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
178
src/App.js
@ -31,7 +31,6 @@ 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';
|
||||
@ -45,8 +44,6 @@ 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';
|
||||
|
||||
|
||||
|
||||
@ -71,16 +68,12 @@ 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
|
||||
------------------------------------------ */
|
||||
@ -106,48 +99,6 @@ 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' ||
|
||||
@ -200,6 +151,18 @@ const showPremiumCTA = !premiumPaths.some(p =>
|
||||
location.pathname.startsWith(p)
|
||||
);
|
||||
|
||||
|
||||
// Helper to see if user is mid–premium-onboarding
|
||||
function isOnboardingInProgress() {
|
||||
try {
|
||||
const stored = JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}');
|
||||
// If step < 4 (example), user is in progress
|
||||
return stored.step && stored.step < 4;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==============================
|
||||
// 1) Single Rehydrate UseEffect
|
||||
// ==============================
|
||||
@ -225,15 +188,10 @@ if (loggingOut) return;
|
||||
(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// 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');
|
||||
// axios client already: withCredentials + Bearer from authMemory
|
||||
const { data } = await api.get('/api/user-profile');
|
||||
if (cancelled) return;
|
||||
setUser(prev => ({
|
||||
...(prev || {}),
|
||||
firstname : data?.firstname || '',
|
||||
is_premium : !!data?.is_premium,
|
||||
is_pro_premium: !!data?.is_pro_premium,
|
||||
}));
|
||||
setUser(data);
|
||||
setIsAuthenticated(true);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
@ -260,6 +218,14 @@ 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
|
||||
// ==========================
|
||||
@ -273,12 +239,14 @@ if (loggingOut) return;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const confirmLogout = async () => {
|
||||
setLoggingOut(true);
|
||||
// 1) Ask the server to clear the session cookie
|
||||
try {
|
||||
// If you created /logout (no /api prefix):
|
||||
await api.post('/api/logout', {}); // axios client is withCredentials: true
|
||||
await api.post('api/logout'); // axios client is withCredentials: true
|
||||
// If your route is /api/signout instead, use:
|
||||
// await api.post('/api/signout');
|
||||
} catch (e) {
|
||||
@ -296,7 +264,6 @@ const confirmLogout = async () => {
|
||||
'aiClickDate',
|
||||
'aiRecommendations',
|
||||
'premiumOnboardingState',
|
||||
'premiumOnboardingPointer',
|
||||
'financialProfile',
|
||||
'selectedScenario',
|
||||
]);
|
||||
@ -329,6 +296,8 @@ const cancelLogout = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// =====================
|
||||
// Main Render / Layout
|
||||
// =====================
|
||||
@ -589,6 +558,7 @@ const cancelLogout = () => {
|
||||
<SupportModal
|
||||
open={supportOpen}
|
||||
onClose={() => setSupportOpen(false)}
|
||||
userEmail={userEmail}
|
||||
/>
|
||||
|
||||
{/* LOGOUT BUTTON */}
|
||||
@ -657,33 +627,81 @@ const cancelLogout = () => {
|
||||
|
||||
|
||||
<Route path="/paywall" element={<Paywall />} />
|
||||
<Route path="/verify" element={<Verify />} />
|
||||
|
||||
{/* Authenticated routes */}
|
||||
{isAuthenticated && (
|
||||
<>
|
||||
<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="/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="/billing" element={<BillingResult />} />
|
||||
|
||||
{/* Premium-wrapped */}
|
||||
<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>} />
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,47 +1,45 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useContext } 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 q = new URLSearchParams(useLocation().search);
|
||||
const outcome = q.get('ck'); // 'success' | 'cancel' | 'portal' | null
|
||||
|
||||
const { setUser } = useContext(ProfileCtx) || {};
|
||||
const q = new URLSearchParams(useLocation().search);
|
||||
const outcome = q.get('ck'); // 'success' | 'cancel' | null
|
||||
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
|
||||
// ─────────────────────────────────────────────────────────
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
1) Ask the API for the latest user profile (flags, etc.)
|
||||
cookies + in-mem token handled by apiClient
|
||||
───────────────────────────────────────────────────────── */
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
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,
|
||||
});
|
||||
}
|
||||
const { data } = await api.get('/api/user-profile');
|
||||
if (!cancelled && data && setUser) setUser(data);
|
||||
} catch (err) {
|
||||
console.warn('[BillingResult] failed to refresh flags', err?.message);
|
||||
// Non-fatal here; UI still shows outcome
|
||||
console.warn('[BillingResult] failed to refresh profile', err?.response?.status || err?.message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [setUser]);
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
2) UX while waiting for that round‑trip
|
||||
───────────────────────────────────────────────────────── */
|
||||
if (loading) {
|
||||
return <p className="p-8 text-center">Checking your subscription…</p>;
|
||||
}
|
||||
|
||||
const hasPremium = flags.is_premium || flags.is_pro_premium;
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 2) Success (Checkout completed)
|
||||
// ─────────────────────────────────────────────────────────
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
3) Success – Stripe completed the checkout flow
|
||||
───────────────────────────────────────────────────────── */
|
||||
if (outcome === 'success') {
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-8 text-center space-y-6">
|
||||
@ -49,59 +47,29 @@ 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">Set up Premium Features</Link>
|
||||
<Link to="/premium-onboarding" className="block w-full">Set up Premium Features</Link>
|
||||
</Button>
|
||||
|
||||
<Button variant="secondary" asChild className="w-full">
|
||||
<Link to="/profile">Go to my account</Link>
|
||||
<Link to="/profile" className="block w-full">Go to my account</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────
|
||||
// 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
|
||||
// ─────────────────────────────────────────────────────────
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
4) Cancelled – user backed out of Stripe
|
||||
───────────────────────────────────────────────────────── */
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-8 text-center space-y-6">
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{hasPremium ? 'Subscription active' : 'No subscription changes'}
|
||||
</h1>
|
||||
<p className="text-gray-600">
|
||||
{hasPremium
|
||||
? 'You still have premium access.'
|
||||
: 'No active subscription on your account.'}
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold">Subscription cancelled</h1>
|
||||
<p className="text-gray-600">No changes were made to your account.</p>
|
||||
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/profile">Go to my account</Link>
|
||||
<Link to="/paywall" className="block w-full">Back to pricing</Link>
|
||||
</Button>
|
||||
{!hasPremium && (
|
||||
<Button asChild className="w-full">
|
||||
<Link to="/paywall">Back to pricing</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,17 +22,11 @@ const nav = useNavigate();
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function remove(row) {
|
||||
async function remove(id) {
|
||||
if (!window.confirm('Delete this career profile?')) return;
|
||||
try {
|
||||
const r = await apiFetch(`/api/premium/career-profile/by-fields`, {
|
||||
const r = await apiFetch(`/api/premium/career-profile/${id}`, {
|
||||
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
|
||||
@ -40,13 +34,7 @@ const nav = useNavigate();
|
||||
alert(msg || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
setRows(prev => prev.filter(x =>
|
||||
!(
|
||||
(x.scenario_title || '') === (row.scenario_title || '') &&
|
||||
(x.career_name || '') === (row.career_name || '') &&
|
||||
(x.start_date || '') === (row.start_date || '')
|
||||
)
|
||||
));
|
||||
setRows(prev => prev.filter(row => row.id !== id));
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
alert('Failed to delete');
|
||||
@ -81,11 +69,13 @@ const nav = useNavigate();
|
||||
<td className="p-2">{r.start_date}</td>
|
||||
<td className="p-2 space-x-2">
|
||||
<Link
|
||||
to={`/profile/careers/${encodeURIComponent(r.id)}/edit`}
|
||||
to={`/profile/careers/${r.id}/edit`}
|
||||
className="underline text-blue-600"
|
||||
>edit</Link>
|
||||
>
|
||||
edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => remove(r)}
|
||||
onClick={() => remove(r.id)}
|
||||
className="text-red-600 underline"
|
||||
>
|
||||
delete
|
||||
@ -95,21 +85,8 @@ const nav = useNavigate();
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<tr>
|
||||
<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 colSpan={5} className="p-4 text-center text-gray-500">
|
||||
No career profiles yet
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
@ -345,6 +345,7 @@ 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);
|
||||
@ -526,7 +527,6 @@ 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?fields=area,state');
|
||||
const up = await authFetch('/api/user-profile');
|
||||
if (up.ok && (up.headers.get('content-type')||'').includes('application/json')) {
|
||||
setUserProfile(await up.json());
|
||||
}
|
||||
@ -691,51 +691,20 @@ useEffect(() => {
|
||||
}
|
||||
}, [recommendations]);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 2) load JSON => masterCareerRatings
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
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);
|
||||
const { data } = await api.get('/api/data/careers-with-ratings');
|
||||
setMasterCareerRatings(data || []);
|
||||
} catch (err) {
|
||||
console.error('Error loading career ratings via API =>', err);
|
||||
setMasterCareerRatings([]);
|
||||
}
|
||||
}, [scenarioRow, selectedCareer]);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
|
||||
// 3) fetch user’s career-profiles
|
||||
// utilities you already have in this file
|
||||
@ -920,6 +889,27 @@ 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 () => {
|
||||
@ -941,8 +931,6 @@ const refetchScenario = useCallback(async () => {
|
||||
})();
|
||||
}, [fullSocCode, scenarioRow]);
|
||||
|
||||
useEffect(() => { resolveSoc(); }, [resolveSoc]);
|
||||
|
||||
async function fetchAiRisk(socCode, careerName, description, tasks) {
|
||||
let aiRisk = null;
|
||||
|
||||
|
||||
@ -1,9 +1,7 @@
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState } 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
|
||||
/** -----------------------------------------------------------
|
||||
@ -50,26 +48,21 @@ 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 [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 schoolData = cipRows;
|
||||
|
||||
const [form, setForm] = useState({
|
||||
career_profile_id : careerId,
|
||||
@ -88,10 +81,6 @@ 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;
|
||||
@ -109,11 +98,6 @@ 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;
|
||||
}
|
||||
@ -121,100 +105,59 @@ const handleFieldChange = (e) => {
|
||||
});
|
||||
};
|
||||
|
||||
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) {
|
||||
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) {
|
||||
setProgSug([]);
|
||||
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;
|
||||
return;
|
||||
}
|
||||
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 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]);
|
||||
|
||||
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') {
|
||||
@ -231,204 +174,130 @@ const onProgramInput = async (e) => {
|
||||
is_online : !!raw.is_online,
|
||||
loan_deferral_until_graduation : !!raw.loan_deferral_until_graduation,
|
||||
};
|
||||
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 {}
|
||||
}
|
||||
setForm(normalized);
|
||||
if (normalized.tuition !== undefined && normalized.tuition !== null) {
|
||||
setManualTuition(String(normalized.tuition));
|
||||
}
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [careerId, id]);
|
||||
|
||||
|
||||
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 {}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
// 2) keep manualTuition aligned if form.tuition is updated elsewhere
|
||||
useEffect(() => {
|
||||
(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;
|
||||
if (form.tuition !== undefined && form.tuition !== null) {
|
||||
if (manualTuition.trim() === '') {
|
||||
setManualTuition(String(form.tuition));
|
||||
}
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [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);}
|
||||
}
|
||||
|
||||
/* LOAD iPEDS ----------------------------- */
|
||||
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 in‑state / 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));
|
||||
}, [
|
||||
selectedUnitId,
|
||||
ipeds,
|
||||
cipRows,
|
||||
form.selected_school,
|
||||
form.program_type,
|
||||
form.credit_hours_per_year,
|
||||
form.is_in_state,
|
||||
form.is_in_district,
|
||||
manualTuition // include so clearing manual → auto resumes immediately
|
||||
form.is_in_district
|
||||
]);
|
||||
|
||||
const handleManualTuitionChange = e => setManualTuition(e.target.value);
|
||||
const chosenTuition = (() => {
|
||||
if (manualTuition.trim() === '') return autoTuition;
|
||||
const n = parseFloat(manualTuition);
|
||||
return Number.isFinite(n) ? n : autoTuition;
|
||||
})();
|
||||
|
||||
/* ────────────────────────────────────────────────────────────
|
||||
Auto‑calculate PROGRAM LENGTH when the user hasn’t typed in
|
||||
@ -437,7 +306,7 @@ useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (programLengthTouched) return; // user override
|
||||
// if a program_length already exists (e.g., from API), don't overwrite it
|
||||
// user override
|
||||
if (form.program_length !== '' && form.program_length != null) return; // user override
|
||||
|
||||
const chpy = parseFloat(form.credit_hours_per_year);
|
||||
if (!chpy || chpy <= 0) return;
|
||||
@ -472,40 +341,32 @@ 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;
|
||||
|
||||
// Mirror Onboarding’s start date selection
|
||||
let start = null;
|
||||
if (form.college_enrollment_status === 'prospective_student') {
|
||||
if (!form.enrollment_date) return; // need user’s 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 start = form.enrollment_date
|
||||
? moment(form.enrollment_date)
|
||||
: moment();
|
||||
|
||||
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">
|
||||
@ -540,31 +401,22 @@ return (
|
||||
name="selected_school"
|
||||
value={form.selected_school}
|
||||
onChange={onSchoolInput}
|
||||
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 === '');
|
||||
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.');
|
||||
}}
|
||||
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={`${s.unitId ?? i}:${s.name}`} value={s.name} />))}
|
||||
{schoolSug.map((s,i)=>(
|
||||
<option key={i} value={s} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
@ -577,40 +429,33 @@ return (
|
||||
name="selected_program"
|
||||
value={form.selected_program}
|
||||
onChange={onProgramInput}
|
||||
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"
|
||||
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"
|
||||
placeholder="Start typing and choose…"
|
||||
className={`w-full border rounded p-2 ${
|
||||
(form.selected_program || '').trim() !== '' && !programValid ? 'border-red-500' : '' }`}
|
||||
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
|
||||
required
|
||||
/>
|
||||
<datalist id="program-suggestions">
|
||||
{progSug.map((p,i)=>(
|
||||
<option key={`${i}:${p.program}`} value={p.program} />
|
||||
))}
|
||||
</datalist>
|
||||
<datalist id="program-suggestions">
|
||||
{progSug.map((p,i)=>(
|
||||
<option key={i} value={p} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
{/* 4 │ Program‑type */}
|
||||
<div className="space-y-1">
|
||||
<label className="block font-medium">Degree Type *</label>
|
||||
{(() => {
|
||||
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
|
||||
<select
|
||||
name="program_type"
|
||||
value={form.program_type}
|
||||
onChange={handleFieldChange}
|
||||
@ -618,9 +463,8 @@ return (
|
||||
required
|
||||
>
|
||||
<option value="">Select Program Type</option>
|
||||
{list.map((t,i)=><option key={`${t}-${i}`} value={t}>{t}</option>)}
|
||||
</select>);
|
||||
})()}
|
||||
{types.map((t,i)=><option key={i} value={t}>{t}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* 5 │ Academic calendar */}
|
||||
@ -710,26 +554,14 @@ return (
|
||||
className="w-full border rounded p-2"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
|
||||
|
||||
{/* 8 │ Existing debt */}
|
||||
@ -829,26 +661,15 @@ return (
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!schoolValid || !programValid}
|
||||
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>
|
||||
);
|
||||
|
||||
|
||||
@ -51,37 +51,14 @@ export default function CollegeProfileList() {
|
||||
}, []);
|
||||
|
||||
/* ───────── delete helper ───────── */
|
||||
async function handleDelete(row) {
|
||||
async function handleDelete(id) {
|
||||
if (!window.confirm("Delete this college plan?")) return;
|
||||
try {
|
||||
const res = await authFetch(`/api/premium/college-profile/by-fields`, {
|
||||
const res = await authFetch(`/api/premium/college-profile/${id}`, {
|
||||
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(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||'')
|
||||
)
|
||||
));
|
||||
setRows((r) => r.filter((row) => row.id !== id));
|
||||
} catch (err) {
|
||||
console.error("Delete failed:", err);
|
||||
alert("Could not delete – see console.");
|
||||
@ -111,10 +88,8 @@ export default function CollegeProfileList() {
|
||||
loading={loadingCareers}
|
||||
authFetch={authFetch}
|
||||
onChange={(careerObj) => {
|
||||
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)}`);
|
||||
if (!careerObj?.id) return;
|
||||
navigate(`/profile/college/${careerObj.id}/new`);
|
||||
}}
|
||||
/>
|
||||
<div className="mt-2 text-right">
|
||||
@ -143,18 +118,20 @@ export default function CollegeProfileList() {
|
||||
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={`${r.career_title}|${r.selected_school}|${r.selected_program}|${r.created_at}`} className="border-t">
|
||||
<tr key={r.id} 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/${encodeURIComponent(r.career_profile_id)}/edit`}
|
||||
to={`/profile/college/${r.career_profile_id}/${r.id}`}
|
||||
className="underline text-blue-600"
|
||||
>edit</Link>
|
||||
>
|
||||
edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(r)}
|
||||
onClick={() => handleDelete(r.id)}
|
||||
className="underline text-red-600"
|
||||
>
|
||||
delete
|
||||
|
||||
@ -188,9 +188,8 @@ const handleSelectSchool = async (school) => {
|
||||
|
||||
// 1) normalize college fields
|
||||
const selected_school = school?.INSTNM || '';
|
||||
const selected_program = (school?.CIPDESC || '');
|
||||
const selected_program = (school?.CIPDESC || '').replace(/\.\s*$/, '');
|
||||
const program_type = school?.CREDDESC || '';
|
||||
const unit_id = school?.UNITID || '';
|
||||
|
||||
// 2) merge into the cookie-backed draft (don’t clobber existing sections)
|
||||
let draft = null;
|
||||
@ -198,17 +197,17 @@ const handleSelectSchool = async (school) => {
|
||||
const existing = draft?.data || {};
|
||||
|
||||
await saveDraft({
|
||||
step: 0,
|
||||
data: {
|
||||
id: draft?.id || null,
|
||||
step: draft?.step ?? 0,
|
||||
careerData: existing.careerData || {},
|
||||
financialData: existing.financialData || {},
|
||||
collegeData: {
|
||||
...(existing.collegeData || {}),
|
||||
selected_school,
|
||||
selected_program,
|
||||
program_type,
|
||||
unit_id,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
});
|
||||
|
||||
// 3) navigate (state is optional now that draft persists)
|
||||
navigate('/career-roadmap', {
|
||||
@ -219,7 +218,7 @@ const handleSelectSchool = async (school) => {
|
||||
INSTNM: school.INSTNM,
|
||||
CIPDESC: selected_program,
|
||||
CREDDESC: program_type,
|
||||
UNITID: unit_id
|
||||
UNITID: school.UNITID ?? null,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -293,7 +292,7 @@ useEffect(() => {
|
||||
useEffect(() => {
|
||||
async function loadUserProfile() {
|
||||
try {
|
||||
const { data } = await api.get('/api/user-profile?fields=zipcode,area');
|
||||
const { data } = await api.get('/api/user-profile');
|
||||
setUserZip(data.zipcode || '');
|
||||
setUserState(data.state || '');
|
||||
} catch (err) {
|
||||
|
||||
@ -64,7 +64,7 @@ const InterestInventory = () => {
|
||||
|
||||
const fetchUserProfile = async () => {
|
||||
try {
|
||||
const res = await authFetch('/api/user-profile?fields=interest_inventory_answers', { method: 'GET' });
|
||||
const res = await authFetch('/api/user-profile', { method: 'GET' });
|
||||
if (!res || !res.ok) throw new Error('Failed to fetch user profile');
|
||||
const data = await res.json();
|
||||
setUserProfile(data);
|
||||
@ -135,10 +135,19 @@ 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({ interest_inventory_answers: answers }),
|
||||
});
|
||||
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,
|
||||
}),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error saving answers to user profile:', err.message);
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
// CareerOnboarding.js
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { saveDraft, clearDraft, loadDraft } from '../../utils/onboardingDraftApi.js';
|
||||
|
||||
@ -18,10 +18,9 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
|
||||
return JSON.parse(localStorage.getItem('selectedCareer') || 'null');
|
||||
} catch { return null; }
|
||||
});
|
||||
const [currentlyWorking, setCurrentlyWorking] = useState(data.currently_working || '');
|
||||
const [collegeStatus, setCollegeStatus] = useState(data.college_enrollment_status || '');
|
||||
const [currentlyWorking, setCurrentlyWorking] = useState('');
|
||||
const [collegeStatus, setCollegeStatus] = useState('');
|
||||
const [showFinPrompt, setShowFinPrompt] = useState(false);
|
||||
const finPromptShownRef = useRef(false);
|
||||
|
||||
/* ── 2. derived helpers ───────────────────────────────────── */
|
||||
const selectedCareerTitle = careerObj?.title || '';
|
||||
@ -40,62 +39,24 @@ const CareerOnboarding = ({ nextStep, prevStep, data, setData, finishNow }) => {
|
||||
if (!navCareerObj?.title) return;
|
||||
|
||||
setCareerObj(navCareerObj);
|
||||
localStorage.setItem('selectedCareer', JSON.stringify({
|
||||
title: navCareerObj.title,
|
||||
soc_code: navCareerObj.soc_code || navCareerObj.code || ''
|
||||
}));
|
||||
localStorage.setItem('selectedCareer', JSON.stringify(navCareerObj));
|
||||
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
career_name : navCareerObj.title,
|
||||
soc_code : navCareerObj.soc_code || navCareerObj.code || navCareerObj.socCode || ''
|
||||
soc_code : navCareerObj.soc_code || ''
|
||||
}));
|
||||
// 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'].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','career_goals'].includes(k)) {
|
||||
saveDraft({ careerData: { [k]: e.target.value } }).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
/* ── 4. callbacks ─────────────────────────────────────────── */
|
||||
function handleCareerSelected(career) {
|
||||
@ -207,9 +168,10 @@ const nextLabel = !skipFin ? 'Financial →' : (inCollege ? 'College →' : 'Fin
|
||||
value={collegeStatus}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setCollegeStatus(val);
|
||||
setData(prev => ({ ...prev, college_enrollment_status: val }));
|
||||
saveDraft({ careerData: { college_enrollment_status: val } }).catch(() => {});
|
||||
setCurrentlyWorking(val);
|
||||
setData(prev => ({ ...prev, currently_working: val }));
|
||||
// persist immediately
|
||||
saveDraft({ careerData: { currently_working: val } }).catch(() => {});
|
||||
}}
|
||||
required
|
||||
className="w-full border rounded p-2"
|
||||
@ -233,7 +195,6 @@ 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);
|
||||
}}
|
||||
>
|
||||
@ -245,7 +206,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
|
||||
}}
|
||||
>
|
||||
@ -261,12 +222,6 @@ 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..."
|
||||
/>
|
||||
|
||||
@ -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(true);
|
||||
const [programValid, setProgramValid] = useState(true);
|
||||
const [schoolValid, setSchoolValid] = useState(false);
|
||||
const [programValid, setProgramValid] = useState(false);
|
||||
const [enrollmentDate, setEnrollmentDate] = useState(
|
||||
data.enrollment_date || ''
|
||||
);
|
||||
@ -26,23 +26,15 @@ const [expectedGraduation, setExpectedGraduation] = useState(data.expected_g
|
||||
location.state?.premiumOnboardingState?.selectedSchool;
|
||||
|
||||
const [selectedSchool, setSelectedSchool] = useState(() => {
|
||||
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 (navSelectedSchoolObj && typeof navSelectedSchoolObj === 'object') {
|
||||
return { INSTNM: navSelectedSchoolObj.INSTNM,
|
||||
CIPDESC: navSelectedSchoolObj.CIPDESC || '',
|
||||
CREDDESC: navSelectedSchoolObj.CREDDESC || '' };
|
||||
}
|
||||
if (data.selected_school) {
|
||||
return {
|
||||
INSTNM: data.selected_school,
|
||||
CIPDESC: data.selected_program || '',
|
||||
CREDDESC: data.program_type || '',
|
||||
UNITID: null
|
||||
};
|
||||
return { INSTNM: data.selected_school,
|
||||
CIPDESC: data.selected_program || '',
|
||||
CREDDESC: data.program_type || '' };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@ -66,9 +58,9 @@ function toSchoolName(objOrStr) {
|
||||
// Destructure parent data
|
||||
const {
|
||||
college_enrollment_status = '',
|
||||
selected_school: top_selected_school = '',
|
||||
selected_program: top_selected_program = '',
|
||||
program_type: top_program_type = '',
|
||||
selected_school = '',
|
||||
selected_program = '',
|
||||
program_type = '',
|
||||
academic_calendar = 'semester',
|
||||
annual_financial_aid = '',
|
||||
is_online = false,
|
||||
@ -88,10 +80,6 @@ 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);
|
||||
@ -112,93 +100,40 @@ function toSchoolName(objOrStr) {
|
||||
}
|
||||
}, [selectedSchool, setData]);
|
||||
|
||||
// If a UNITID came in (from EducationalPrograms nav or draft), use it for auto-tuition
|
||||
// Backfill from cookie-backed draft if props aren't populated yet
|
||||
useEffect(() => {
|
||||
if (selectedSchool?.UNITID && !selectedUnitId) {
|
||||
setSelectedUnitId(selectedSchool.UNITID);
|
||||
}
|
||||
}, [selectedSchool, selectedUnitId]);
|
||||
// if props already have values, do nothing
|
||||
if (data?.selected_school || data?.selected_program || data?.program_type) return;
|
||||
|
||||
// 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 || cancelled) return;
|
||||
if (!cd) return;
|
||||
if (cancelled) return;
|
||||
|
||||
// 1) write into parent data (so inputs prefill)
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
selected_school : prev.selected_school || cd.selected_school || '',
|
||||
selected_program: prev.selected_program || cd.selected_program || '',
|
||||
program_type : prev.program_type || cd.program_type || ''
|
||||
selected_school : cd.selected_school ?? prev.selected_school ?? '',
|
||||
selected_program: cd.selected_program ?? prev.selected_program ?? '',
|
||||
program_type : cd.program_type ?? prev.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);
|
||||
// 2) set local selectedSchool object (triggers your selectedSchool→data effect too)
|
||||
setSelectedSchool({
|
||||
INSTNM : cd.selected_school || '',
|
||||
CIPDESC : cd.selected_program || '',
|
||||
CREDDESC: cd.program_type || ''
|
||||
});
|
||||
})();
|
||||
|
||||
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);
|
||||
@ -302,7 +237,7 @@ useEffect(() => {
|
||||
setSchoolSuggestions([]);
|
||||
setProgramSuggestions([]);
|
||||
setAvailableProgramTypes([]);
|
||||
saveDraft({ collegeData: { selected_school: name, unit_id: uid } }).catch(() => {});
|
||||
saveDraft({ collegeData: { selected_school: name } }).catch(() => {});
|
||||
};
|
||||
|
||||
// Program
|
||||
@ -409,15 +344,15 @@ useEffect(() => {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const hasSchool = !!selected_school;
|
||||
const hasAnyProgram = !!selected_program || !!program_type;
|
||||
const hasSchool = !!data.selected_school;
|
||||
const hasAnyProgram = !!data.selected_program || !!data.program_type;
|
||||
if (!hasSchool && !hasAnyProgram) return;
|
||||
|
||||
setSelectedSchool(prev => {
|
||||
const next = {
|
||||
INSTNM : selected_school || '',
|
||||
CIPDESC : selected_program || '',
|
||||
CREDDESC: program_type || ''
|
||||
INSTNM : data.selected_school || '',
|
||||
CIPDESC : data.selected_program || '',
|
||||
CREDDESC: data.program_type || ''
|
||||
};
|
||||
// avoid useless state churn
|
||||
if (prev &&
|
||||
@ -426,7 +361,7 @@ useEffect(() => {
|
||||
prev.CREDDESC=== next.CREDDESC) return prev;
|
||||
return next;
|
||||
});
|
||||
}, [selected_school, selected_program, program_type]);
|
||||
}, [data.selected_school, data.selected_program, data.program_type]);
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Whenever the user changes enrollmentDate OR programLength */
|
||||
@ -465,26 +400,6 @@ 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);
|
||||
@ -500,12 +415,6 @@ useEffect(() => {
|
||||
program_length: chosenProgramLength
|
||||
}));
|
||||
|
||||
saveDraft({ collegeData: {
|
||||
tuition: Number(chosenTuition) || 0,
|
||||
program_length: Number(chosenProgramLength) || 0,
|
||||
expected_graduation: expectedGraduation || ''
|
||||
}}).catch(()=>{});
|
||||
|
||||
nextStep();
|
||||
};
|
||||
|
||||
@ -547,10 +456,8 @@ 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>
|
||||
|
||||
@ -560,7 +467,6 @@ 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>
|
||||
@ -572,7 +478,6 @@ 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>
|
||||
@ -584,7 +489,6 @@ 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>
|
||||
@ -597,19 +501,15 @@ const ready =
|
||||
name="selected_school"
|
||||
value={selected_school}
|
||||
onChange={handleSchoolChange}
|
||||
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);
|
||||
}}
|
||||
|
||||
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.");
|
||||
}}
|
||||
list="school-suggestions"
|
||||
className={`w-full border rounded p-2 ${ (selected_school || '').trim() !== '' && !schoolValid ? 'border-red-500' : ''}`}
|
||||
className={`w-full border rounded p-2 ${schoolValid ? '' : 'border-red-500'}`}
|
||||
placeholder="Start typing and choose…"
|
||||
/>
|
||||
<datalist id="school-suggestions">
|
||||
@ -629,15 +529,13 @@ const ready =
|
||||
name="selected_program"
|
||||
value={selected_program}
|
||||
onChange={handleProgramChange}
|
||||
onBlur={() => {
|
||||
const trimmed = (selected_program || '').trim();
|
||||
const exact = programSuggestions.find(p =>
|
||||
(p.program || '').toLowerCase() === trimmed.toLowerCase()
|
||||
);
|
||||
setProgramValid(trimmed === '' || !!exact);
|
||||
onBlur={() => {
|
||||
const ok = !!programSuggestions.find(p => (p.program || '').toLowerCase() === (selected_program || '').toLowerCase());
|
||||
setProgramValid(ok);
|
||||
if (!ok) alert("Please pick a program from the list.");
|
||||
}}
|
||||
list="program-suggestions"
|
||||
className={`w-full border rounded p-2 ${(selected_program || '').trim() !== '' && !programValid ? 'border-red-500' : ''}`}
|
||||
className={`w-full border rounded p-2 ${programValid ? '' : 'border-red-500'}`}
|
||||
placeholder="Start typing and choose…"
|
||||
/>
|
||||
<datalist id="program-suggestions">
|
||||
@ -692,7 +590,6 @@ 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>
|
||||
@ -706,7 +603,6 @@ 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>
|
||||
@ -733,7 +629,6 @@ 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
|
||||
@ -754,7 +649,6 @@ 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>
|
||||
@ -786,7 +680,6 @@ 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>
|
||||
@ -807,7 +700,6 @@ 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
|
||||
/>
|
||||
@ -834,7 +726,6 @@ 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
|
||||
/>
|
||||
@ -850,7 +741,6 @@ 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>
|
||||
@ -863,7 +753,6 @@ 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>
|
||||
@ -876,7 +765,6 @@ 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>
|
||||
@ -889,7 +777,6 @@ 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>
|
||||
@ -921,9 +808,11 @@ const ready =
|
||||
{showAidWizard && (
|
||||
<Modal onClose={() => setShowAidWizard(false)}>
|
||||
<FinancialAidWizard
|
||||
onAidEstimated={(estimate) => {
|
||||
setData(prev => ({ ...prev, annual_financial_aid: estimate }));
|
||||
saveDraft({ collegeData: { annual_financial_aid: estimate } }).catch(() => {});
|
||||
onAidEstimated={(estimate) => {
|
||||
setData(prev => ({
|
||||
...prev,
|
||||
annual_financial_aid: estimate
|
||||
}));
|
||||
}}
|
||||
onClose={() => setShowAidWizard(false)}
|
||||
/>
|
||||
|
||||
@ -74,24 +74,20 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
}
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
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
|
||||
setData(prevData => ({ ...prevData, [name]: val }));
|
||||
saveDraft({
|
||||
financialData: {
|
||||
extra_cash_emergency_pct: val,
|
||||
extra_cash_retirement_pct: 100 - val
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
}).catch(()=>{});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
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();
|
||||
};
|
||||
// Move to next step
|
||||
nextStep();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-md mx-auto p-6 space-y-6">
|
||||
@ -109,10 +105,6 @@ 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>
|
||||
@ -127,10 +119,6 @@ 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>
|
||||
@ -148,10 +136,6 @@ 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
|
||||
@ -172,10 +156,6 @@ 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>
|
||||
@ -190,10 +170,6 @@ 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>
|
||||
@ -208,10 +184,6 @@ 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>
|
||||
@ -226,10 +198,6 @@ 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>
|
||||
@ -244,10 +212,6 @@ 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>
|
||||
@ -268,10 +232,6 @@ 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>
|
||||
@ -284,10 +244,6 @@ 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>
|
||||
@ -301,7 +257,7 @@ const FinancialOnboarding = ({ nextStep, prevStep, data, setData }) => {
|
||||
← Previous: Career
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
onClick={nextStep}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white font-semibold py-2 px-4 rounded"
|
||||
>
|
||||
Next: College →
|
||||
|
||||
@ -8,7 +8,6 @@ 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';
|
||||
|
||||
@ -29,6 +28,30 @@ 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 {
|
||||
@ -50,14 +73,6 @@ 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);
|
||||
@ -81,78 +96,40 @@ 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 — send only changed slices ----------
|
||||
const prevCareerJsonRef = useRef('');
|
||||
const prevFinancialJsonRef = useRef('');
|
||||
const prevCollegeJsonRef = useRef('');
|
||||
|
||||
// ---- 2) debounced autosave to server + pointer update ----------
|
||||
useEffect(() => {
|
||||
if (!loaded) return;
|
||||
if (!loaded) return;
|
||||
|
||||
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]);
|
||||
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
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [loaded, step, careerData, financialData, collegeData]);
|
||||
|
||||
// ---- nav helpers ------------------------------------------------
|
||||
const nextStep = () => setStep((s) => s + 1);
|
||||
@ -213,7 +190,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);
|
||||
|
||||
@ -5,7 +5,7 @@ import * as safeLocal from '../utils/safeLocal.js';
|
||||
|
||||
function SignIn({ setIsAuthenticated, setUser }) {
|
||||
const navigate = useNavigate();
|
||||
const { setScenario } = useContext(ProfileCtx);
|
||||
const { setFinancialProfile, setScenario } = useContext(ProfileCtx);
|
||||
const usernameRef = useRef('');
|
||||
const passwordRef = useRef('');
|
||||
const [error, setError] = useState('');
|
||||
@ -55,27 +55,20 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
|
||||
|
||||
// Fetch ONLY the minimal fields needed for the landing UI
|
||||
// (requires server1 to honor ?fields=… — otherwise remove `fields=` until it’s 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 (_) {}
|
||||
// Optional: keep allowlisted id if provided by API
|
||||
if (data.id) localStorage.setItem('id', data.id);
|
||||
|
||||
// 3) Establish app auth state with minimal user info
|
||||
setScenario(null);
|
||||
setIsAuthenticated(true);
|
||||
setUser(minimalUser);
|
||||
// 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);
|
||||
|
||||
navigate('/signin-landing');
|
||||
} catch (err) {
|
||||
|
||||
@ -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 as much information as possible to make informed career decisions. Today’s workplace is changing faster than ever, driven largely by AI—but our goal is to use that same technology to empower job seekers, not replace them.
|
||||
At AptivaAI, we aim to arm you with all the knowledge and guidance we wish we had when making our own career decisions. Today’s workplace is changing faster than ever, driven largely by AI—but our goal is to use that same technology to empower job seekers, not replace them.
|
||||
|
||||
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 driver’s seat of your career. Whether you’re planning your first step, advancing your current role, or ready to pivot entirely, our platform keeps you in control—helping you adapt, grow, and thrive on your own terms.
|
||||
We blend data-backed insights with human-centered design, giving you practical recommendations and real-world context so you stay in the driver’s seat of your career. Whether you’re planning your first step, enhancing your current role, or ready to pivot entirely, our platform keeps you in control—helping 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, enhanced -not driven b
|
||||
</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 Exploring
|
||||
Go to Planning
|
||||
</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-green-600 text-white rounded hover:bg-blue-700">
|
||||
<Link to="/enhancing" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Go to Enhancing
|
||||
</Link>
|
||||
<Link to="/retirement" className="inline-block px-4 py-2 bg-green-600 text-white rounded hover:bg-blue-700">
|
||||
<Link to="/retirement" className="inline-block px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Go to Retirement
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
// 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 }) {
|
||||
export default function SupportModal({ open, onClose, userEmail }) {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [category, setCategory] = useState('general');
|
||||
const [message, setMessage] = useState('');
|
||||
@ -16,30 +15,29 @@ export default function SupportModal({ open, onClose }) {
|
||||
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({ subject, category, message })
|
||||
});
|
||||
|
||||
if (!res) throw new Error('Your session expired. Please sign in again.');
|
||||
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.');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j.error || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
|
||||
setOk(true);
|
||||
setSubject('');
|
||||
setSubject(''); // reset to empty strings, not null
|
||||
setCategory('general');
|
||||
setMessage('');
|
||||
setTimeout(() => onClose(), 1200);
|
||||
@ -48,7 +46,7 @@ export default function SupportModal({ open, onClose }) {
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
@ -58,12 +56,22 @@ export default function SupportModal({ open, onClose }) {
|
||||
<button onClick={onClose} className="text-sm underline">Close</button>
|
||||
</div>
|
||||
|
||||
{/* Informational note — no PII fetched/rendered */}
|
||||
<p className="text-xs text-gray-600 mb-3">
|
||||
We’ll reply to the email associated with your account.
|
||||
</p>
|
||||
{!userEmail && (
|
||||
<p className="text-sm text-red-600 mb-2">
|
||||
You must be signed in to contact support.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm block mb-1">We’ll 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
|
||||
@ -108,7 +116,7 @@ export default function SupportModal({ open, onClose }) {
|
||||
<Button type="button" className="bg-gray-200 text-black" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={sending}>
|
||||
<Button type="submit" disabled={sending || !userEmail}>
|
||||
{sending ? 'Sending…' : 'Send'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@ -77,14 +77,7 @@ function UserProfile() {
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await authFetch('/api/user-profile?fields=' +
|
||||
[
|
||||
'firstname','lastname','email',
|
||||
'zipcode','state','area','career_situation',
|
||||
'phone_e164','sms_opt_in'
|
||||
].join(','),
|
||||
{ method: 'GET' }
|
||||
);
|
||||
const res = await authFetch('/api/user-profile', { method: 'GET' });
|
||||
if (!res || !res.ok) return;
|
||||
const data = await res.json();
|
||||
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
// /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;
|
||||
}
|
||||
@ -1,171 +0,0 @@
|
||||
// /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>
|
||||
);
|
||||
}
|
||||
@ -1,37 +1,33 @@
|
||||
// utils/onboardingDraftApi.js
|
||||
import authFetch from './authFetch.js';
|
||||
import authFetch from './authFetch.js';
|
||||
|
||||
// Always same-origin so the session cookie goes with it
|
||||
const DRAFT_URL = '/api/premium/onboarding/draft';
|
||||
const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, '');
|
||||
const DRAFT_URL = `${API_ROOT}/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. Never POSTs an empty data blob.
|
||||
* Merges partials with the current server draft to avoid clobbering.
|
||||
*/
|
||||
export async function saveDraft(input = {}) {
|
||||
// Normalize inputs
|
||||
let { id = null, step = 0, data } = input;
|
||||
|
||||
// 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 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.
|
||||
if (data == null) {
|
||||
// Merge any section patches with existing draft; skip POST if literally nothing to save.
|
||||
// Load existing draft (may be null/404 for first-time)
|
||||
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);
|
||||
@ -40,32 +36,12 @@ 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' },
|
||||
@ -76,9 +52,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;
|
||||
}
|
||||
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 }
|
||||
}
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@ -1,253 +0,0 @@
|
||||
# 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 can’t accommodate every job title—choose the closest match to what you’re 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]
|
||||
```
|
||||
|
Before Width: | Height: | Size: 98 KiB |
@ -1,253 +0,0 @@
|
||||
# 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 can’t accommodate every job title—choose the closest match to what you’re 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]
|
||||
```
|
||||
|
Before Width: | Height: | Size: 98 KiB |
@ -1,34 +0,0 @@
|
||||
# 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 can’t accommodate every job title—choose the closest match to what you’re searching for.
|
||||
- paragraph [ref=e33]: After you pick a career, we’ll display matching educational programs.
|
||||
- button "Open chat" [ref=e34] [cursor=pointer]:
|
||||
- img [ref=e35] [cursor=pointer]
|
||||
```
|
||||
|
Before Width: | Height: | Size: 55 KiB |
@ -1,34 +0,0 @@
|
||||
# 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 can’t accommodate every job title—choose the closest match to what you’re searching for.
|
||||
- paragraph [ref=e33]: After you pick a career, we’ll display matching educational programs.
|
||||
- button "Open chat" [ref=e34] [cursor=pointer]:
|
||||
- img [ref=e35] [cursor=pointer]
|
||||
```
|
||||
|
Before Width: | Height: | Size: 55 KiB |
@ -1,92 +0,0 @@
|
||||
# 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 can’t accommodate every job title—choose the closest match to what you’re 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]
|
||||
```
|
||||
|
Before Width: | Height: | Size: 91 KiB |
@ -1,22 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 28 KiB |
@ -1,23 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 26 KiB |
@ -1,23 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 26 KiB |
@ -1,22 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 28 KiB |
@ -1,22 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 28 KiB |
@ -1,22 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 28 KiB |
@ -1,22 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 28 KiB |
@ -1,22 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 28 KiB |
@ -1,22 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 28 KiB |
@ -1,22 +0,0 @@
|
||||
# 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: Don’t have an account?
|
||||
- link "Sign Up" [ref=e17] [cursor=pointer]:
|
||||
- /url: /signup
|
||||
- link "Forgot your password?" [ref=e19] [cursor=pointer]:
|
||||
- /url: /forgot-password
|
||||
```
|
||||
|
Before Width: | Height: | Size: 28 KiB |
@ -1,28 +0,0 @@
|
||||
# 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]
|
||||
```
|
||||
|
Before Width: | Height: | Size: 36 KiB |
@ -1,109 +0,0 @@
|
||||
// @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 didn’t 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([]);
|
||||
});
|
||||
});
|
||||
@ -1,38 +0,0 @@
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
@ -1,58 +0,0 @@
|
||||
// @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 });
|
||||
});
|
||||
});
|
||||
@ -1,305 +0,0 @@
|
||||
// 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 there’s 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 (don’t 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -1,151 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@ -1,111 +0,0 @@
|
||||
|
||||
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 (don’t 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('');
|
||||
});
|
||||
});
|
||||
@ -1,178 +0,0 @@
|
||||
// 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();
|
||||
});
|
||||
});
|
||||
@ -1,28 +0,0 @@
|
||||
// @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 });
|
||||
});
|
||||
});
|
||||
@ -1,122 +0,0 @@
|
||||
// @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();
|
||||
}
|
||||
});
|
||||
});
|
||||