+ Using your most recently updated college plan + {collegeProfile.updated_at ? ` (${moment(collegeProfile.updated_at).format('YYYY-MM')})` : ''}. +
+ )} {/* ───────────── Title ───────────── */}diff --git a/.build.hash b/.build.hash index 97c76bb..f5825b3 100644 --- a/.build.hash +++ b/.build.hash @@ -1 +1 @@ -767a2e51259e707655c80d6449afa93abf982fec-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b +408b293acaaa053b934050f88b9c93db41ecb097-372bcf506971f56c4911b429b9f5de5bc37ed008-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/backend/config/env.js b/backend/config/env.js index 16b71c6..45e39b7 100644 --- a/backend/config/env.js +++ b/backend/config/env.js @@ -8,7 +8,7 @@ const __dirname = path.dirname(__filename); // repo root = two levels up from /backend/config const repoRoot = path.resolve(__dirname, '..', '..'); -const env = (process.env.NODE_ENV || 'development').trim(); +const env = (process.env.ENV_NAME || 'prod').trim(); // Prefer .env.development / .env.production — fall back to plain .env const fileA = path.join(repoRoot, `.env.${env}`); diff --git a/backend/server1.js b/backend/server1.js index efacfdb..6eec2a6 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -27,7 +27,7 @@ const CANARY_SQL = ` const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const rootPath = path.resolve(__dirname, '..'); -const env = process.env.NODE_ENV?.trim() || 'development'; +const isProd = (process.env.ENV_NAME === 'prod'); const envPath = path.resolve(rootPath, `.env.${env}`); dotenv.config({ path: envPath, override: false }); @@ -778,7 +778,10 @@ app.post('/api/auth/verify/email/send', requireAuth, verifySendLimiter, async (r text, html: `
${text}`
});
- return res.status(200).json({ ok: true });
+ // In non-production, include token to enable E2E to complete verification without email I/O.
+ const extra = (process.env.ENV_NAME === 'prod') ? {} : { test_token: token };
+ return res.status(200).json({ ok: true, ...extra });
+
} catch (e) {
console.error('[verify/email/send]', e?.message || e);
return res.status(500).json({ error: 'Failed to send verification email' });
@@ -1092,7 +1095,7 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
career_list = ?,
phone_e164 = ?,
sms_opt_in = ?,
- sms_reminders_opt_in = ?
+ sms_reminders_opt_in = ?,
sms_reminders_opt_in_at =
CASE
WHEN ? = 1 AND (sms_reminders_opt_in IS NULL OR sms_reminders_opt_in = 0)
@@ -1118,6 +1121,7 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
phoneFinal,
smsOptFinal,
smsRemindersFinal,
+ smsRemindersFinal,
profileId
];
@@ -1125,14 +1129,16 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
return res.status(200).json({ message: 'User profile updated successfully' });
} else {
// INSERT branch
- const insertQuery = `
+ const insertQuery = `
INSERT INTO user_profile
(id, username, firstname, lastname, email, email_lookup, zipcode, state, area,
- career_situation, interest_inventory_answers, riasec_scores,
- career_priorities, career_list, phone_e164, sms_opt_in, sms_reminders_opt_in)
+ career_situation, interest_inventory_answers, riasec_scores,
+ career_priorities, career_list, phone_e164, sms_opt_in, sms_reminders_opt_in,
+ sms_reminders_opt_in_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
- ?, ?, ?,
- ?, ?, ?, ?, ?)
+ ?, ?, ?,
+ ?, ?, ?, ?, ?,
+ CASE WHEN ? = 1 THEN UTC_TIMESTAMP() ELSE NULL END)
`;
const params = [
profileId,
@@ -1151,7 +1157,8 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
finalCareerList,
phoneFinal,
smsOptFinal,
- smsRemindersFinal
+ smsRemindersFinal,
+ smsRemindersFinal,
];
diff --git a/backend/server2.js b/backend/server2.js
index 3226149..1a02f84 100755
--- a/backend/server2.js
+++ b/backend/server2.js
@@ -32,7 +32,7 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootPath = path.resolve(__dirname, '..');
-const env = process.env.NODE_ENV?.trim() || 'development';
+const isProd = (process.env.ENV_NAME === 'prod');
const envPath = path.resolve(rootPath, `.env.${env}`);
dotenv.config({ path: envPath, override: false }); // don't clobber compose-injected env
@@ -46,7 +46,7 @@ const DB_POOL_SIZE = 6;
const API_BASE = (process.env.APTIVA_INTERNAL_API || 'http://server1:5000').replace(/\/+$/, '');
const REQUIRED_FILES = [CIP_TO_SOC_PATH, INSTITUTION_DATA_PATH, SALARY_DB_PATH];
-if (process.env.NODE_ENV !== 'production') REQUIRED_FILES.push(USER_PROFILE_DB_PATH);
+if (process.env.ENV_NAME !== 'prod') REQUIRED_FILES.push(USER_PROFILE_DB_PATH);
for (const p of REQUIRED_FILES) {
if (!fs.existsSync(p)) {
console.error(`FATAL Required data file not found → ${p}`);
diff --git a/backend/server3.js b/backend/server3.js
index b879e0e..11677f5 100644
--- a/backend/server3.js
+++ b/backend/server3.js
@@ -34,7 +34,7 @@ import './jobs/reminderCron.js';
import { cacheSummary } from "./utils/ctxCache.js";
const rootPath = path.resolve(__dirname, '..');
-const env = (process.env.NODE_ENV || 'prod');
+const env = (process.env.ENV_NAME || 'prod');
const envPath = path.resolve(rootPath, `.env.${env}`);
if (!process.env.FROM_SECRETS_MANAGER) {
dotenv.config({ path: envPath, override: false });
@@ -602,7 +602,7 @@ app.post(
return res.status(400).end();
}
// Env guard: only handle events matching our env
- const isProd = (process.env.NODE_ENV === 'prod');
+ const isProd = (process.env.ENV_NAME === 'prod');
if (Boolean(event.livemode) !== isProd) {
console.warn('[Stripe] Ignoring webhook due to livemode mismatch', { livemode: event.livemode, isProd });
return res.sendStatus(200);
@@ -1455,14 +1455,17 @@ I'm here to support you with personalized coaching—what would you like to focu
salaryAnalysis = null,
economicProjections = null
}) {
+ const _userProfile = userProfile || {};
+ const _scenarioRow = scenarioRow || {};
+ const _financialProfile = financialProfile || {};
+ const _collegeProfile = collegeProfile || {};
// 1) USER PROFILE
const firstName = userProfile.firstname || "N/A";
const lastName = userProfile.lastname || "N/A";
const fullName = `${firstName} ${lastName}`;
- const username = userProfile.username || "N/A";
- const location = userProfile.area || userProfile.state || "Unknown Region";
- // userProfile.career_situation might be "enhancing", "preparing", etc.
- const careerSituation = userProfile.career_situation || "Not provided";
+ const username = _userProfile.username || "N/A";
+ const location = _userProfile.area || _userProfile.state || "Unknown Region";
+ const careerSituation = _userProfile.career_situation || "Not provided";
// RIASEC
let riasecText = "None";
@@ -1485,10 +1488,10 @@ I'm here to support you with personalized coaching—what would you like to focu
// Possibly parse "career_priorities" if you need them
let careerPriorities = "Not provided";
- if (userProfile.career_priorities) {
+ if (_userProfile.career_priorities) {
// e.g. "career_priorities": "{\"interests\":\"Somewhat important\",\"meaning\":\"Somewhat important\",\"stability\":\"Very important\", ...}"
try {
- const cP = JSON.parse(userProfile.career_priorities);
+ const cP = JSON.parse(_userProfile.career_priorities);
// Build a bullet string
careerPriorities = Object.entries(cP).map(([k,v]) => `- ${k}: ${v}`).join("\n");
} catch(e) {
@@ -1499,30 +1502,29 @@ I'm here to support you with personalized coaching—what would you like to focu
// 2) CAREER SCENARIO
// scenarioRow might have career_name, job_description, tasks
// but you said sometimes you store them in scenarioRow or pass them in a separate param
- const careerName = scenarioRow.career_name || "No career selected";
- const socCode = scenarioRow.soc_code || "N/A";
- const jobDescription = scenarioRow.job_description || "No jobDescription info";
- // scenarioRow.tasks might be an array
- const tasksList = Array.isArray(scenarioRow.tasks) && scenarioRow.tasks.length
- ? scenarioRow.tasks.join(", ")
+ const careerName = _scenarioRow.career_name || "No career selected";
+ const socCode = _scenarioRow.soc_code || "N/A";
+ const jobDescription = _scenarioRow.job_description || "No jobDescription info";
+ const tasksList = Array.isArray(_scenarioRow.tasks) && _scenarioRow.tasks.length
+ ? _scenarioRow.tasks.join(", ")
: "No tasks info";
// 3) FINANCIAL PROFILE
// your actual JSON uses e.g. "current_salary", "additional_income"
- const currentSalary = financialProfile.current_salary || 0;
- const additionalIncome = financialProfile.additional_income || 0;
- const monthlyExpenses = financialProfile.monthly_expenses || 0;
- const monthlyDebt = financialProfile.monthly_debt_payments || 0;
- const retirementSavings = financialProfile.retirement_savings || 0;
- const emergencyFund = financialProfile.emergency_fund || 0;
+ const currentSalary = _financialProfile.current_salary || 0;
+ const additionalIncome = _financialProfile.additional_income || 0;
+ const monthlyExpenses = _financialProfile.monthly_expenses || 0;
+ const monthlyDebt = _financialProfile.monthly_debt_payments || 0;
+ const retirementSavings = _financialProfile.retirement_savings || 0;
+ const emergencyFund = _financialProfile.emergency_fund || 0;
// 4) COLLEGE PROFILE
// from your JSON:
- const selectedProgram = collegeProfile.selected_program || "N/A";
- const enrollmentStatus = collegeProfile.college_enrollment_status || "Not enrolled";
- const creditHoursCompleted = parseFloat(collegeProfile.hours_completed) || 0;
- const programLength = parseFloat(collegeProfile.program_length) || 0;
- const expectedGraduation = collegeProfile.expected_graduation || "Unknown";
+ const selectedProgram = _collegeProfile?.selected_program ?? "N/A";
+ const enrollmentStatus = _collegeProfile?.college_enrollment_status ?? "Not enrolled";
+ const creditHoursCompleted = parseFloat(_collegeProfile?.hours_completed ?? 0) || 0;
+ const programLength = parseFloat(_collegeProfile?.program_length ?? 0) || 0;
+ const expectedGraduation = _collegeProfile?.expected_graduation ?? "Unknown";
// 5) AI RISK
// from aiRisk object
@@ -1678,7 +1680,7 @@ let summaryText = buildUserSummary({
scenarioRow,
userProfile,
financialProfile,
- collegeProfile,
+ collegeProfile: collegeProfile || {},
aiRisk
});
@@ -2502,6 +2504,26 @@ app.post('/api/premium/career-profile/clone', authenticatePremiumUser, async (re
[newId, ...values]
);
+ // 2.5) copy ALL college_profiles tied to the source scenario
+ const [cprows] = await pool.query(
+ 'SELECT * FROM college_profiles WHERE career_profile_id=? AND user_id=?',
+ [sourceId, req.id]
+ );
+ for (const cp of cprows) {
+ const newCpId = uuidv4();
+ const cols = Object.keys(cp).filter(k => !['id','created_at','updated_at'].includes(k));
+ const vals = cols.map(k =>
+ k === 'career_profile_id' ? newId :
+ k === 'user_id' ? req.id :
+ cp[k]
+ );
+ await pool.query(
+ `INSERT INTO college_profiles (id, ${cols.join(',')})
+ VALUES (?, ${cols.map(() => '?').join(',')})`,
+ [newCpId, ...vals]
+ );
+ }
+
// 3) copy milestones/tasks/impacts (optional – mirrors UI wizard)
const [mils] = await pool.query(
'SELECT * FROM milestones WHERE career_profile_id=? AND user_id=?',
@@ -3631,11 +3653,11 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res
const { careerProfileId } = req.query;
try {
const [rows] = await pool.query(
- `SELECT *
+ `SELECT *
FROM college_profiles
WHERE user_id = ?
AND career_profile_id = ?
- ORDER BY created_at DESC
+ ORDER BY updated_at DESC
LIMIT 1`,
[req.id, careerProfileId]
);
diff --git a/backend/utils/seedFaq.js b/backend/utils/seedFaq.js
index d560617..c2517c0 100644
--- a/backend/utils/seedFaq.js
+++ b/backend/utils/seedFaq.js
@@ -10,7 +10,7 @@ import path from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const rootPath = path.resolve(__dirname, "../..");
-const env = process.env.NODE_ENV?.trim() || "development";
+const env = process.env.ENV_NAME?.trim() || "prod";
dotenv.config({ path: path.resolve(rootPath, `.env.${env}`) });
const faqPath = path.resolve(rootPath, "backend", "data", "faqs.json");
diff --git a/blob-report/report-b0bf72e.zip b/blob-report/report-b0bf72e.zip
new file mode 100644
index 0000000..d0afceb
Binary files /dev/null and b/blob-report/report-b0bf72e.zip differ
diff --git a/nginx.conf b/nginx.conf
index 16b13b5..2cdcd55 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -88,6 +88,11 @@ http {
# ───── React static assets ─────
root /usr/share/nginx/html;
index index.html;
+
+ # Redirect only the bare root to /signin (avoid booting shell at '/')
+ location = / {
+ return 302 /signin$is_args$args;
+ }
location / {
try_files $uri $uri/ /index.html;
}
diff --git a/playwright-report/data/1f58ad1d6df01d9b3572bd88f698664ae5f7f8fa.md b/playwright-report/data/1f58ad1d6df01d9b3572bd88f698664ae5f7f8fa.md
new file mode 100644
index 0000000..cac970b
--- /dev/null
+++ b/playwright-report/data/1f58ad1d6df01d9b3572bd88f698664ae5f7f8fa.md
@@ -0,0 +1,58 @@
+# 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 "Verify your account" [level=1] [ref=e25]
+ - paragraph [ref=e26]: You must verify before using AptivaAI.
+ - generic [ref=e27]:
+ - heading "Email verification" [level=2] [ref=e28]
+ - generic [ref=e29]:
+ - button "Send email" [ref=e30] [cursor=pointer]
+ - textbox "Paste token" [ref=e31]
+ - button "Confirm" [ref=e32] [cursor=pointer]
+ - generic [ref=e33]:
+ - heading "Phone verification (optional)" [level=2] [ref=e34]
+ - generic [ref=e35]:
+ - checkbox "By requesting a code, you agree to receive one-time texts from AptivaAI for account verification and security alerts. Message frequency varies. Msg & data rates may apply. Reply STOP to opt out, HELP for help. Consent is not a condition of purchase or service. See the SMS Terms, Privacy Policy, and Terms." [ref=e36]
+ - generic [ref=e37]:
+ - text: By requesting a code, you agree to receive one-time texts from AptivaAI for account verification and security alerts. Message frequency varies. Msg & data rates may apply. Reply
+ - strong [ref=e38]: STOP
+ - text: to opt out,
+ - strong [ref=e39]: HELP
+ - text: for help. Consent is not a condition of purchase or service. See the
+ - link "SMS Terms" [ref=e40] [cursor=pointer]:
+ - /url: /sms
+ - text: ","
+ - link "Privacy Policy" [ref=e41] [cursor=pointer]:
+ - /url: /legal/privacy
+ - text: ", and"
+ - link "Terms" [ref=e42] [cursor=pointer]:
+ - /url: /legal/terms
+ - text: .
+ - generic [ref=e43]:
+ - textbox "+1XXXXXXXXXX" [ref=e44]: "1"
+ - button "Send code" [disabled] [ref=e45]
+ - generic [ref=e46]:
+ - textbox "6-digit code" [ref=e47]
+ - button "Confirm" [disabled] [ref=e48]
+ - button "Open chat" [ref=e49] [cursor=pointer]:
+ - img [ref=e50] [cursor=pointer]
+```
\ No newline at end of file
diff --git a/playwright-report/data/339593b3c0c996cb824cb405aeec283e95c3982f.webm b/playwright-report/data/339593b3c0c996cb824cb405aeec283e95c3982f.webm
new file mode 100644
index 0000000..1c7239b
Binary files /dev/null and b/playwright-report/data/339593b3c0c996cb824cb405aeec283e95c3982f.webm differ
diff --git a/playwright-report/data/f2ac6496f99b830e4aee213d29a4c68f3c4452da.png b/playwright-report/data/f2ac6496f99b830e4aee213d29a4c68f3c4452da.png
new file mode 100644
index 0000000..1a17993
Binary files /dev/null and b/playwright-report/data/f2ac6496f99b830e4aee213d29a4c68f3c4452da.png differ
diff --git a/playwright-report/index.html b/playwright-report/index.html
new file mode 100644
index 0000000..d6c8562
--- /dev/null
+++ b/playwright-report/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+ + Using your most recently updated college plan + {collegeProfile.updated_at ? ` (${moment(collegeProfile.updated_at).format('YYYY-MM')})` : ''}. +
+ )} {/* ───────────── Title ───────────── */}@@ -982,8 +1049,16 @@ return (
Nest Egg
-{usd(retireBalAtMilestone)}
- + {(() => { + // Prefer TOTAL savings at the retirement start month; fall back to retirement-only + const retStart = localScenario?.retirement_start_date + ? moment(localScenario.retirement_start_date).startOf('month').format('YYYY-MM') + : null; + const idx = retStart ? chartLabels.indexOf(retStart) : -1; + const totalAtRet = idx >= 0 ? projectionData[idx]?.totalSavings : null; + const nestEgg = (typeof totalAtRet === 'number' ? totalAtRet : retireBalAtMilestone) || 0; + return{usd(nestEgg)}
; + })()} {/* Money lasts */}Money Lasts
diff --git a/src/components/SignUp.js b/src/components/SignUp.js index 37b5df6..c86752c 100644 --- a/src/components/SignUp.js +++ b/src/components/SignUp.js @@ -90,28 +90,6 @@ function SignUp() { { name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' }, ]; - useEffect(() => { - const fetchAreas = async () => { - if (!state) { - setAreas([]); - return; - } - - setLoadingAreas(true); // Start loading - try { - const res = await fetch(`/api/areas?state=${state}`); - const data = await res.json(); - setAreas(data.areas || []); - } catch (err) { - console.error('Error fetching areas:', err); - setAreas([]); - } finally { - setLoadingAreas(false); // Done loading - } - }; - - fetchAreas(); - }, [state]); const validateFields = async () => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; @@ -241,59 +219,35 @@ const handleSituationConfirm = async () => { }; useEffect(() => { - // reset UI - setAreasErr(''); - if (!state) { setAreas([]); return; } + setAreasErr(''); + if (!state) { setAreas([]); setArea(''); return; } - // cached? instant - if (areasCacheRef.current.has(state)) { - setAreas(areasCacheRef.current.get(state)); - return; + // cancel previous request if any + if (inflightRef.current) inflightRef.current.abort(); + const controller = new AbortController(); + inflightRef.current = controller; + + setLoadingAreas(true); + (async () => { + try { + const res = await fetch(`/api/areas?state=${encodeURIComponent(state)}`, { signal: controller.signal }); + if (!res.ok) throw new Error('bad_response'); + const data = await res.json(); + const list = Array.isArray(data?.areas) ? data.areas : []; + setAreas(list); + if (area && !list.includes(area)) setArea(''); + } catch (err) { + if (controller.signal.aborted) return; // superseded by a newer request + setAreas([]); + setAreasErr('Could not load Areas. Please try again.'); + } finally { + if (inflightRef.current === controller) inflightRef.current = null; + setLoadingAreas(false); } + })(); - // debounce to avoid rapid refetch on quick clicks - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(async () => { - // cancel previous request if any - if (inflightRef.current) inflightRef.current.abort(); - const controller = new AbortController(); - inflightRef.current = controller; - - setLoadingAreas(true); - try { - // client-side timeout race (6s) - const timeout = new Promise((_, rej) => - setTimeout(() => rej(new Error('timeout')), 6000) - ); - - const res = await Promise.race([ - fetch(`/api/areas?state=${encodeURIComponent(state)}`, { - signal: controller.signal, - }), - timeout, - ]); - - if (!res || !res.ok) throw new Error('bad_response'); - const data = await res.json(); - - // normalize, uniq, sort for UX - const list = Array.from(new Set((data.areas || []).filter(Boolean))).sort(); - areasCacheRef.current.set(state, list); // cache it - setAreas(list); - } catch (err) { - if (err.name === 'AbortError') return; // superseded by a newer request - setAreas([]); - setAreasErr('Could not load Areas. You can proceed without selecting one.'); - } finally { - if (inflightRef.current === controller) inflightRef.current = null; - setLoadingAreas(false); - } - }, 250); // 250ms debounce - - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [state]); + return () => { controller.abort(); }; +}, [state]); return (