removed files from tracking, dependencies, fixed encryption
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
Josh 2025-08-19 12:24:54 +00:00
parent 2d9e63af32
commit 5838f782e7
52 changed files with 1888 additions and 1450 deletions

1
.build.hash Normal file
View File

@ -0,0 +1 @@
fcb1ff42e88c57ae313a74da813f6a3cdb19904f-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b

5
.gitignore vendored
View File

@ -22,4 +22,7 @@ yarn-error.log*
_logout _logout
env/*.env env/*.env
*.env *.env
uploads/ uploads/.env
.env
.env.*
scan-env.sh

1
.last-lock Normal file
View File

@ -0,0 +1 @@
8eca4afbc834297a74d0c140a17e370c19102dea

1
.last-node Normal file
View File

@ -0,0 +1 @@
v20.19.0

1
.lock.hash Normal file
View File

@ -0,0 +1 @@
8eca4afbc834297a74d0c140a17e370c19102dea

View File

@ -110,6 +110,8 @@ steps:
export DEK_PATH; \ export DEK_PATH; \
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \ SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
export SUPPORT_SENDGRID_API_KEY; \ export SUPPORT_SENDGRID_API_KEY; \
GOOGLE_MAPS_API_KEY=$(gcloud secrets versions access latest --secret=GOOGLE_MAPS_API_KEY_$ENV --project=$PROJECT); \
export GOOGLE_MAPS_API_KEY; \
export FROM_SECRETS_MANAGER=true; \ export FROM_SECRETS_MANAGER=true; \
\ \
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \ # ── DEK sync: copy dev wrapped DEK into staging volume path ── \
@ -127,9 +129,9 @@ steps:
fi; \ fi; \
\ \
cd /home/jcoakley/aptiva-staging-app; \ cd /home/jcoakley/aptiva-staging-app; \
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY \ sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,GOOGLE_MAPS_API_KEY \
docker compose pull; \ docker compose pull; \
sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY \ sudo --preserve-env=IMG_TAG,FROM_SECRETS_MANAGER,JWT_SECRET,OPENAI_API_KEY,ONET_USERNAME,ONET_PASSWORD,STRIPE_SECRET_KEY,STRIPE_PUBLISHABLE_KEY,STRIPE_WH_SECRET,STRIPE_PRICE_PREMIUM_MONTH,STRIPE_PRICE_PREMIUM_YEAR,STRIPE_PRICE_PRO_MONTH,STRIPE_PRICE_PRO_YEAR,DB_NAME,DB_HOST,DB_PORT,DB_USER,DB_PASSWORD,DB_SSL_CA,DB_SSL_CERT,DB_SSL_KEY,TWILIO_ACCOUNT_SID,TWILIO_AUTH_TOKEN,TWILIO_MESSAGING_SERVICE_SID,KMS_KEY_NAME,DEK_PATH,SUPPORT_SENDGRID_API_KEY,GOOGLE_MAPS_API_KEY \
docker compose up -d --force-recreate --remove-orphans; \ docker compose up -d --force-recreate --remove-orphans; \
echo "✅ Staging stack refreshed with tag $IMG_TAG"' echo "✅ Staging stack refreshed with tag $IMG_TAG"'

View File

@ -1,16 +1,16 @@
// backend/jobs/reminderCron.js // backend/jobs/reminderCron.js
import cron from 'node-cron'; import cron from 'node-cron';
import pool from '../config/mysqlPool.js'; import pool from '../config/mysqlPool.js';
import { sendSMS } from '../utils/smsService.js'; import { sendSMS } from '../utils/smsService.js';
import { query } from '../shared/db/withEncryption.js';
const BATCH_SIZE = 25; // tune as you like const BATCH_SIZE = 25;
/* Every minute */ /* Every minute */
cron.schedule('*/1 * * * *', async () => { cron.schedule('*/1 * * * *', async () => {
try { try {
/* 1⃣ Fetch at most BATCH_SIZE reminders that are due */ // IMPORTANT: use execute() so the param is truly bound
const [rows] = await pool.query(
const [rows] = await pool.execute(
`SELECT id, `SELECT id,
phone_e164 AS toNumber, phone_e164 AS toNumber,
message_body AS body message_body AS body
@ -19,29 +19,21 @@ cron.schedule('*/1 * * * *', async () => {
AND send_at_utc <= UTC_TIMESTAMP() AND send_at_utc <= UTC_TIMESTAMP()
ORDER BY send_at_utc ASC ORDER BY send_at_utc ASC
LIMIT ?`, LIMIT ?`,
[BATCH_SIZE] [BATCH_SIZE] // must be a number
); );
if (!rows.length) return; // nothing to do if (!rows.length) return;
let sent = 0, failed = 0; let sent = 0, failed = 0;
/* 2⃣ Fire off each SMS (sendSMS handles its own DB status update) */
for (const r of rows) { for (const r of rows) {
try { try {
await sendSMS({ // ← updated signature await sendSMS({ reminderId: r.id, to: r.toNumber, body: r.body });
reminderId: r.id,
to : r.toNumber,
body : r.body
});
sent++; sent++;
} catch (err) { } catch (err) {
console.error('[reminderCron] Twilio error:', err?.message || err); console.error('[reminderCron] Twilio error:', err?.message || err);
failed++; failed++;
/* sendSMS already logged the failure + updated status */
} }
} }
console.log(`[reminderCron] processed ${rows.length}: ${sent} sent, ${failed} failed`); console.log(`[reminderCron] processed ${rows.length}: ${sent} sent, ${failed} failed`);
} catch (err) { } catch (err) {
console.error('[reminderCron] DB error:', err); console.error('[reminderCron] DB error:', err);

View File

@ -15,6 +15,7 @@ import sgMail from '@sendgrid/mail';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz import { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js'; import { requireAuth } from './shared/requireAuth.js';
import cookieParser from 'cookie-parser';
const CANARY_SQL = ` const CANARY_SQL = `
CREATE TABLE IF NOT EXISTS encryption_canary ( CREATE TABLE IF NOT EXISTS encryption_canary (
@ -85,16 +86,10 @@ try {
const app = express(); const app = express();
const PORT = process.env.SERVER1_PORT || 5000; const PORT = process.env.SERVER1_PORT || 5000;
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(Boolean);
app.disable('x-powered-by'); app.disable('x-powered-by');
app.use(bodyParser.json());
app.use(express.json()); app.use(express.json());
app.set('trust proxy', 1); // important if you're behind a proxy/HTTPS terminator
app.use(cookieParser());
app.use( app.use(
helmet({ helmet({
contentSecurityPolicy: false, contentSecurityPolicy: false,
@ -102,6 +97,29 @@ app.use(
}) })
); );
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(Boolean);
function sessionCookieOptions() {
const IS_PROD = process.env.NODE_ENV === 'production';
const CROSS_SITE = process.env.CROSS_SITE_COOKIES === '1'; // set to "1" if FE and API are different sites
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
return {
httpOnly: true,
secure: IS_PROD, // <-- not secure in local dev
sameSite: CROSS_SITE ? 'none' : 'lax',
path: '/',
maxAge: 2 * 60 * 60 * 1000,
...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}),
};
}
const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || 'aptiva_session';
function fprPathFromEnv() { function fprPathFromEnv() {
const p = (process.env.DEK_PATH || '').trim(); const p = (process.env.DEK_PATH || '').trim();
return p ? path.join(path.dirname(p), 'dek.fpr') : null; return p ? path.join(path.dirname(p), 'dek.fpr') : null;
@ -223,13 +241,12 @@ app.use(
app.options('*', (req, res) => { app.options('*', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || ''); res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader( res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
'Access-Control-Allow-Headers', res.setHeader('Access-Control-Allow-Credentials', 'true'); // <-- add this
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
);
res.status(200).end(); res.status(200).end();
}); });
// Add HTTP headers for security // Add HTTP headers for security
app.use((req, res, next) => { app.use((req, res, next) => {
res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Content-Type-Options', 'nosniff');
@ -579,6 +596,7 @@ app.post('/api/register', async (req, res) => {
await pool.query(authQuery, [newProfileId, username, hashedPassword]); await pool.query(authQuery, [newProfileId, username, hashedPassword]);
const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' }); const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
return res.status(201).json({ return res.status(201).json({
message: 'User registered successfully', message: 'User registered successfully',
@ -667,7 +685,8 @@ if (profile?.email) {
} }
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' }); const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
res.status(200).json({ res.status(200).json({
message: 'Login successful', message: 'Login successful',
@ -683,6 +702,10 @@ if (profile?.email) {
} }
}); });
app.post('/api/logout', (_req, res) => {
res.clearCookie(COOKIE_NAME, sessionCookieOptions());
return res.status(200).json({ ok: true });
});
/* ------------------------------------------------------------------ /* ------------------------------------------------------------------

View File

@ -14,15 +14,16 @@ import sqlite3 from 'sqlite3';
import pool from './config/mysqlPool.js'; // exports { query, execute, raw, ... } import pool from './config/mysqlPool.js'; // exports { query, execute, raw, ... }
import fs from 'fs'; import fs from 'fs';
import { readFile } from 'fs/promises'; // <-- add this import { readFile } from 'fs/promises'; // <-- add this
import readline from 'readline';
import chatFreeEndpoint from "./utils/chatFreeEndpoint.js"; import chatFreeEndpoint from "./utils/chatFreeEndpoint.js";
import { OpenAI } from 'openai'; import { OpenAI } from 'openai';
import rateLimit from 'express-rate-limit'; import rateLimit from 'express-rate-limit';
import authenticateUser from './utils/authenticateUser.js'; import authenticateUser from './utils/authenticateUser.js';
import { vectorSearch } from "./utils/vectorSearch.js"; import { vectorSearch } from "./utils/vectorSearch.js";
import { initEncryption, verifyCanary, SENTINEL } from './shared/crypto/encryption.js'; import { initEncryption, verifyCanary } from './shared/crypto/encryption.js';
import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail import sgMail from '@sendgrid/mail'; // npm i @sendgrid/mail
import crypto from 'crypto'; import crypto from 'crypto';
import cookieParser from 'cookie-parser';
import { v4 as uuid } from 'uuid';
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@ -38,6 +39,7 @@ const CIP_TO_SOC_PATH = path.join(PUBLIC_DIR, 'CIP_to_ONET_SOC.xlsx');
const INSTITUTION_DATA_PATH= path.join(PUBLIC_DIR, 'Institution_data.json'); const INSTITUTION_DATA_PATH= path.join(PUBLIC_DIR, 'Institution_data.json');
const SALARY_DB_PATH = path.join(ROOT_DIR, 'salary_info.db'); const SALARY_DB_PATH = path.join(ROOT_DIR, 'salary_info.db');
const USER_PROFILE_DB_PATH = path.join(ROOT_DIR, 'user_profile.db'); const USER_PROFILE_DB_PATH = path.join(ROOT_DIR, 'user_profile.db');
const API_BASE = (process.env.APTIVA_API_BASE || 'http://server1:5000').replace(/\/+$/,'');
for (const p of [CIP_TO_SOC_PATH, INSTITUTION_DATA_PATH, SALARY_DB_PATH, USER_PROFILE_DB_PATH]) { for (const p of [CIP_TO_SOC_PATH, INSTITUTION_DATA_PATH, SALARY_DB_PATH, USER_PROFILE_DB_PATH]) {
if (!fs.existsSync(p)) { if (!fs.existsSync(p)) {
@ -72,6 +74,7 @@ try {
// Create Express app // Create Express app
const app = express(); const app = express();
const PORT = process.env.SERVER2_PORT || 5001; const PORT = process.env.SERVER2_PORT || 5001;
app.use(cookieParser());
function fprPathFromEnv() { function fprPathFromEnv() {
const p = (process.env.DEK_PATH || '').trim(); const p = (process.env.DEK_PATH || '').trim();
@ -221,9 +224,6 @@ async function initDatabases() {
await initDatabases(); await initDatabases();
// …rest of your routes and app.listen(PORT)
/* /*
* SECURITY, CORS, JSON Body * SECURITY, CORS, JSON Body
* */ * */
@ -507,11 +507,12 @@ app.post('/api/onet/submit_answers', async (req, res) => {
console.error('Invalid answers:', answers); console.error('Invalid answers:', answers);
return res.status(400).json({ error: 'Answers must be 60 chars long.' }); return res.status(400).json({ error: 'Answers must be 60 chars long.' });
} }
try { try {
const careerUrl = `https://services.onetcenter.org/ws/mnm/interestprofiler/careers?answers=${answers}&start=1&end=1000`; const careerUrl = `https://services.onetcenter.org/ws/mnm/interestprofiler/careers?answers=${answers}&start=1&end=1000`;
const resultsUrl = `https://services.onetcenter.org/ws/mnm/interestprofiler/results?answers=${answers}`; const resultsUrl = `https://services.onetcenter.org/ws/mnm/interestprofiler/results?answers=${answers}`;
// career suggestions // O*NET calls → Basic Auth only
const careerResponse = await axios.get(careerUrl, { const careerResponse = await axios.get(careerUrl, {
auth: { auth: {
username: process.env.ONET_USERNAME, username: process.env.ONET_USERNAME,
@ -519,7 +520,7 @@ app.post('/api/onet/submit_answers', async (req, res) => {
}, },
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
// RIASEC
const resultsResponse = await axios.get(resultsUrl, { const resultsResponse = await axios.get(resultsUrl, {
auth: { auth: {
username: process.env.ONET_USERNAME, username: process.env.ONET_USERNAME,
@ -531,24 +532,27 @@ app.post('/api/onet/submit_answers', async (req, res) => {
const careerSuggestions = careerResponse.data.career || []; const careerSuggestions = careerResponse.data.career || [];
const riaSecScores = resultsResponse.data.result || []; const riaSecScores = resultsResponse.data.result || [];
// filter out lower ed
const filtered = filterHigherEducationCareers(careerSuggestions); const filtered = filterHigherEducationCareers(careerSuggestions);
const riasecCode = convertToRiasecCode(riaSecScores); const riasecCode = convertToRiasecCode(riaSecScores);
const token = req.headers.authorization?.split(' ')[1]; // Pass the caller's Bearer straight through to server1 (if present)
if (token) { const bearer = req.headers.authorization; // e.g. "Bearer eyJ..."
if (bearer) {
try { try {
await axios.post('/api/user-profile', await axios.post(
`${API_BASE}/api/user-profile`,
{ {
interest_inventory_answers: answers, interest_inventory_answers: answers,
riasec: riasecCode riasec: riasecCode,
}, },
{ headers: { Authorization: `Bearer ${token}` } } { headers: { Authorization: bearer } }
); );
} catch (err) { } catch (err) {
console.error('Error storing RIASEC in user_profile =>', err.response?.data || err.message); console.error(
// fallback if needed 'Error storing RIASEC in user_profile =>',
err.response?.data || err.message
);
// non-fatal for the O*NET response
} }
} }
@ -564,6 +568,9 @@ app.post('/api/onet/submit_answers', async (req, res) => {
}); });
} }
}); });
function filterHigherEducationCareers(careers) { function filterHigherEducationCareers(careers) {
return careers return careers
.map((c) => { .map((c) => {
@ -1246,6 +1253,126 @@ ${body}`;
} }
); );
/* ----------------- Support chat threads ----------------- */
app.post('/api/support/chat/threads', authenticateUser, async (req, res) => {
const userId = req.user.id;
const id = uuid();
const title = (req.body?.title || 'Support chat').slice(0, 200);
await pool.query(
'INSERT INTO ai_chat_threads (id,user_id,bot_type,title) VALUES (?,?, "support", ?)',
[id, userId, title]
);
res.json({ id, title });
});
app.get('/api/support/chat/threads', authenticateUser, async (req, res) => {
const [rows] = await pool.query(
'SELECT id,title,updated_at FROM ai_chat_threads WHERE user_id=? AND bot_type="support" ORDER BY updated_at DESC LIMIT 50',
[req.user.id]
);
res.json({ threads: rows });
});
app.get('/api/support/chat/threads/:id', authenticateUser, async (req, res) => {
const { id } = req.params;
const [[t]] = await pool.query(
'SELECT id FROM ai_chat_threads WHERE id=? AND user_id=? AND bot_type="support"',
[id, req.user.id]
);
if (!t) return res.status(404).json({ error: 'not_found' });
const [msgs] = await pool.query(
'SELECT role,content,created_at FROM ai_chat_messages WHERE thread_id=? ORDER BY id ASC LIMIT 200',
[id]
);
res.json({ messages: msgs });
});
/* ---- STREAM proxy: saves user msg, calls your /api/chat/free, saves assistant ---- */
app.post('/api/support/chat/threads/:id/stream', authenticateUser, async (req, res) => {
const { id } = req.params;
const userId = req.user.id;
const { prompt = '', pageContext = '', snapshot = null } = req.body || {};
if (!prompt.trim()) return res.status(400).json({ error: 'empty' });
const [[t]] = await pool.query(
'SELECT id FROM ai_chat_threads WHERE id=? AND user_id=? AND bot_type="support"',
[id, userId]
);
if (!t) return res.status(404).json({ error: 'not_found' });
// 1) save user message
await pool.query(
'INSERT INTO ai_chat_messages (thread_id,user_id,role,content) VALUES (?,?, "user", ?)',
[id, userId, prompt]
);
// 2) load last 40 messages as chatHistory for context
const [history] = await pool.query(
'SELECT role,content FROM ai_chat_messages WHERE thread_id=? ORDER BY id ASC LIMIT 40',
[id]
);
// 3) call internal free endpoint (streaming)
const internal = await fetch(`${API_BASE}/chat/free`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
Authorization: req.headers.authorization || ''
},
body: JSON.stringify({
prompt,
pageContext,
snapshot,
chatHistory: history
})
});
if (!internal.ok || !internal.body) {
return res.status(502).json({ error: 'upstream_failed' });
}
// 4) pipe stream to client while buffering assistant text to persist at the end
res.status(200);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const reader = internal.body.getReader();
const encoder = new TextEncoder();
const decoder = new TextDecoder();
let buf = '';
let assistant = '';
async function flush(line) {
assistant += line + '\n';
await res.write(encoder.encode(line + '\n'));
}
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
buf += decoder.decode(value, { stream: true });
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) await flush(line);
}
}
if (buf.trim()) await flush(buf.trim());
// 5) persist assistant message & touch thread
await pool.query(
'INSERT INTO ai_chat_messages (thread_id,user_id,role,content) VALUES (?,?, "assistant", ?)',
[id, userId, assistant.trim()]
);
await pool.query('UPDATE ai_chat_threads SET updated_at=CURRENT_TIMESTAMP WHERE id=?', [id]);
res.end();
});
/************************************************** /**************************************************
* Start the Express server * Start the Express server
**************************************************/ **************************************************/

View File

@ -16,6 +16,7 @@ import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist'; import pkg from 'pdfjs-dist';
import pool from './config/mysqlPool.js'; import pool from './config/mysqlPool.js';
import { v4 as uuid } from 'uuid';
import OpenAI from 'openai'; import OpenAI from 'openai';
import Fuse from 'fuse.js'; import Fuse from 'fuse.js';
@ -24,6 +25,7 @@ import { createReminder } from './utils/smsService.js';
import { initEncryption, verifyCanary } from './shared/crypto/encryption.js'; import { initEncryption, verifyCanary } from './shared/crypto/encryption.js';
import { hashForLookup } from './shared/crypto/encryption.js'; import { hashForLookup } from './shared/crypto/encryption.js';
import cookieParser from 'cookie-parser';
import './jobs/reminderCron.js'; import './jobs/reminderCron.js';
import { cacheSummary } from "./utils/ctxCache.js"; import { cacheSummary } from "./utils/ctxCache.js";
@ -57,6 +59,7 @@ function isSafeRedirect(url) {
} }
const app = express(); const app = express();
app.use(cookieParser());
const { getDocument } = pkg; const { getDocument } = pkg;
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' }); const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, { apiVersion: '2024-04-10' });
@ -214,6 +217,35 @@ 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 });
});
app.post( app.post(
'/api/premium/stripe/webhook', '/api/premium/stripe/webhook',
express.raw({ type: 'application/json' }), express.raw({ type: 'application/json' }),
@ -293,12 +325,12 @@ app.use((req, res, next) => {
'Access-Control-Allow-Headers', 'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods' 'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods'
); );
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
// B) default permissive fallback (same as server2s behaviour) // B) default permissive fallback (same as server2s behaviour)
} else { } else {
res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
res.setHeader( res.setHeader(
'Access-Control-Allow-Headers', 'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With' 'Authorization, Content-Type, Accept, Origin, X-Requested-With'
@ -312,20 +344,17 @@ app.use((req, res, next) => {
}); });
// 3) Authentication middleware // 3) Authentication middleware
const authenticatePremiumUser = (req, res, next) => { function authenticatePremiumUser(req, res, next) {
const token = (req.headers.authorization || '') let token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
.replace(/^Bearer\s+/i, '') // drop “Bearer ” if (!token) token = req.cookies?.[COOKIE_NAME] || req.cookies?.token || '';
.trim(); // strip CR/LF, spaces
if (!token) { if (!token) return res.status(401).json({ error: 'Premium authorization required' });
return res.status(401).json({ error: 'Premium authorization required' });
}
try { try {
const JWT_SECRET = process.env.JWT_SECRET; const { id } = jwt.verify(token, process.env.JWT_SECRET);
const { id } = jwt.verify(token, JWT_SECRET); req.id = id;
req.id = id; // store user ID in request
next(); next();
} catch (error) { } catch {
return res.status(403).json({ error: 'Invalid or expired token' }); return res.status(403).json({ error: 'Invalid or expired token' });
} }
}; };
@ -742,179 +771,6 @@ app.delete('/api/premium/career-profile/:careerProfileId', authenticatePremiumUs
} }
}); });
/***************************************************
AI - NEXT STEPS ENDPOINT (with date constraints,
ignoring scenarioRow.start_date)
****************************************************/
app.post('/api/premium/ai/next-steps', authenticatePremiumUser, async (req, res) => {
try {
// 1) Gather user data from request
const {
userProfile = {},
scenarioRow = {},
financialProfile = {},
collegeProfile = {},
previouslyUsedTitles = []
} = req.body;
// 2) Build a summary for ChatGPT
// (We'll ignore scenarioRow.start_date in the prompt)
// 4. Get / build the cached big-context card (one DB hit, or none on cache-hit)
// build the big summary with your local helper
let summaryText = buildUserSummary({
userProfile,
scenarioRow,
financialProfile,
collegeProfile,
aiRisk
});
summaryText = await cacheSummary(req.id, scenarioRow.id, summaryText);
let avoidSection = '';
if (previouslyUsedTitles.length > 0) {
avoidSection = `\nDO NOT repeat the following milestone titles:\n${previouslyUsedTitles
.map((t) => `- ${t}`)
.join('\n')}\n`;
}
// 3) Dynamically compute "today's" date and future cutoffs
const now = new Date();
const isoToday = now.toISOString().slice(0, 10); // e.g. "2025-06-01"
// short-term = within 6 months
const shortTermLimit = new Date(now);
shortTermLimit.setMonth(shortTermLimit.getMonth() + 6);
const isoShortTermLimit = shortTermLimit.toISOString().slice(0, 10);
// long-term = 1-3 years
const oneYearFromNow = new Date(now);
oneYearFromNow.setFullYear(oneYearFromNow.getFullYear() + 1);
const isoOneYearFromNow = oneYearFromNow.toISOString().slice(0, 10);
const threeYearsFromNow = new Date(now);
threeYearsFromNow.setFullYear(threeYearsFromNow.getFullYear() + 3);
const isoThreeYearsFromNow = threeYearsFromNow.toISOString().slice(0, 10).slice(0, 10);
// 4) Construct ChatGPT messages
const messages = [
{
role: 'system',
content: `
You are an expert career & financial coach.
Today's date: ${isoToday}.
Short-term means any date up to ${isoShortTermLimit} (within 6 months).
Long-term means a date between ${isoOneYearFromNow} and ${isoThreeYearsFromNow} (1-3 years).
All milestone dates must be strictly >= ${isoToday}. Titles must be <= 5 words.
IMPORTANT RESTRICTIONS:
- NEVER suggest specific investments in cryptocurrency, stocks, or other speculative financial instruments.
- NEVER provide specific investment advice without appropriate risk disclosures.
- NEVER provide legal, medical, or psychological advice.
- ALWAYS promote responsible and low-risk financial planning strategies.
- Emphasize skills enhancement, networking, and education as primary pathways to financial success.
Respond ONLY in the requested JSON format.`
},
{
role: 'user',
content: `
Here is the user's current situation:
${summaryText}
Please provide exactly 2 short-term (within 6 months) and 1 long-term (13 years) milestones. Avoid any previously suggested milestones.
Each milestone must have:
- "title" (up to 5 words)
- "date" in YYYY-MM-DD format (>= ${isoToday})
- "description" (1-2 sentences)
${avoidSection}
Return ONLY a JSON array, no extra text:
[
{
"title": "string",
"date": "YYYY-MM-DD",
"description": "string"
},
...
]`
}
];
// 5) Call OpenAI (ignoring scenarioRow.start_date for date logic)
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini', // or 'gpt-4'
messages,
temperature: 0.7,
max_tokens: 600
});
// 6) Extract raw text
const aiAdvice = completion?.choices?.[0]?.message?.content?.trim() || 'No response';
res.json({ recommendations: aiAdvice });
} catch (err) {
console.error('Error in /api/premium/ai/next-steps =>', err);
res.status(500).json({ error: 'Failed to get AI next steps.' });
}
});
/**
* Helper that converts user data into a concise text summary.
* This can still mention scenarioRow, but we do NOT feed
* scenarioRow.start_date to ChatGPT for future date calculations.
*/
function buildUserSummary({
userProfile = {},
scenarioRow = {},
financialProfile = {},
collegeProfile = {},
aiRisk = null
}) {
const location = `${userProfile.state || 'Unknown State'}, ${userProfile.area || 'N/A'}`;
const careerName = scenarioRow.career_name || 'Unknown';
const careerGoals = scenarioRow.career_goals || 'No goals specified';
const status = scenarioRow.status || 'planned';
const currentlyWorking = scenarioRow.currently_working || 'no';
const currentSalary = financialProfile.current_salary || 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;
let riskText = '';
if (aiRisk?.riskLevel) {
riskText = `
AI Automation Risk: ${aiRisk.riskLevel}
Reasoning: ${aiRisk.reasoning}`;
}
return `
User Location: ${location}
Career Name: ${careerName}
Career Goals: ${careerGoals}
Career Status: ${status}
Currently Working: ${currentlyWorking}
Financial:
- Salary: \$${currentSalary}
- Monthly Expenses: \$${monthlyExpenses}
- Monthly Debt: \$${monthlyDebt}
- Retirement Savings: \$${retirementSavings}
- Emergency Fund: \$${emergencyFund}
${riskText}
`.trim();
}
// Example: ai/chat with correct milestone-saving logic
// At the top of server3.js, leave your imports and setup as-is
// (No need to import 'pluralize' if we're no longer using it!)
app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => { app.post('/api/premium/ai/chat', authenticatePremiumUser, async (req, res) => {
try { try {
const { const {
@ -1864,7 +1720,156 @@ Always end with: “AptivaAI is an educational tool not advice.”
} }
); );
/* ------------- Retirement chat threads ------------- */
app.post('/api/premium/retire/chat/threads', authenticatePremiumUser, async (req, res) => {
const id = uuid();
const title = (req.body?.title || 'Retirement chat').slice(0,200);
await pool.query(
'INSERT INTO ai_chat_threads (id,user_id,bot_type,title) VALUES (?,?, "retire", ?)',
[req.id, title]
);
res.json({ id, title });
});
app.get('/api/premium/retire/chat/threads', authenticatePremiumUser, async (req, res) => {
const [rows] = await pool.query(
'SELECT id,title,updated_at FROM ai_chat_threads WHERE user_id=? AND bot_type="retire" ORDER BY updated_at DESC LIMIT 50',
[req.id]
);
res.json({ threads: rows });
});
app.get('/api/premium/retire/chat/threads/:id', authenticatePremiumUser, async (req, res) => {
const { id } = req.params;
const [[t]] = await pool.query(
'SELECT id FROM ai_chat_threads WHERE id=? AND user_id=? AND bot_type="retire"',
[id, req.id]
);
if (!t) return res.status(404).json({ error: 'not_found' });
const [msgs] = await pool.query(
'SELECT role,content,created_at FROM ai_chat_messages WHERE thread_id=? ORDER BY id ASC LIMIT 200',
[id]
);
res.json({ messages: msgs });
});
app.post('/api/premium/retire/chat/threads/:id/messages', authenticatePremiumUser, async (req, res) => {
const { id } = req.params;
const { content = '', context = {} } = req.body || {};
if (!content.trim()) return res.status(400).json({ error: 'empty' });
const [[t]] = await pool.query(
'SELECT id FROM ai_chat_threads WHERE id=? AND user_id=? AND bot_type="retire"',
[id, req.id]
);
if (!t) return res.status(404).json({ error: 'not_found' });
await pool.query(
'INSERT INTO ai_chat_messages (thread_id,user_id,role,content) VALUES (?,?, "user", ?)',
[id, req.id, content]
);
const [history] = await pool.query(
'SELECT role,content FROM ai_chat_messages WHERE thread_id=? ORDER BY id ASC LIMIT 40',
[id]
);
// Call your existing retirement logic (keeps all safety/patch behavior)
const resp = await internalFetch(req, '/premium/retirement/aichat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: content,
scenario_id: context?.scenario_id,
chatHistory: history
})
});
const json = await resp.json();
const reply = (json?.reply || '').trim() || 'Sorry, please try again.';
await pool.query(
'INSERT INTO ai_chat_messages (thread_id,user_id,role,content) VALUES (?,?, "assistant", ?)',
[id, req.id, reply]
);
await pool.query('UPDATE ai_chat_threads SET updated_at=CURRENT_TIMESTAMP WHERE id=?', [id]);
res.json(json); // keep scenarioPatch passthrough
});
/* ------------------ Coach chat threads ------------------ */
app.post('/api/premium/coach/chat/threads', authenticatePremiumUser, async (req, res) => {
const id = uuid();
const title = (req.body?.title || 'CareerCoach chat').slice(0,200);
await pool.query(
'INSERT INTO ai_chat_threads (id,user_id,bot_type,title) VALUES (?,?, "coach", ?)',
[req.id, title]
);
res.json({ id, title });
});
app.get('/api/premium/coach/chat/threads', authenticatePremiumUser, async (req, res) => {
const [rows] = await pool.query(
'SELECT id,title,updated_at FROM ai_chat_threads WHERE user_id=? AND bot_type="coach" ORDER BY updated_at DESC LIMIT 50',
[req.id]
);
res.json({ threads: rows });
});
app.get('/api/premium/coach/chat/threads/:id', authenticatePremiumUser, async (req, res) => {
const { id } = req.params;
const [[t]] = await pool.query(
'SELECT id FROM ai_chat_threads WHERE id=? AND user_id=? AND bot_type="coach"',
[id, req.id]
);
if (!t) return res.status(404).json({ error: 'not_found' });
const [msgs] = await pool.query(
'SELECT role,content,created_at FROM ai_chat_messages WHERE thread_id=? ORDER BY id ASC LIMIT 200',
[id]
);
res.json({ messages: msgs });
});
/* Post a user message → call your existing /api/premium/ai/chat → save both */
app.post('/api/premium/coach/chat/threads/:id/messages', authenticatePremiumUser, async (req, res) => {
const { id } = req.params;
const { content = '', context = {} } = req.body || {};
if (!content.trim()) return res.status(400).json({ error: 'empty' });
const [[t]] = await pool.query(
'SELECT id FROM ai_chat_threads WHERE id=? AND user_id=? AND bot_type="coach"',
[id, req.id]
);
if (!t) return res.status(404).json({ error: 'not_found' });
await pool.query(
'INSERT INTO ai_chat_messages (thread_id,user_id,role,content) VALUES (?,?, "user", ?)',
[id, req.id, content]
);
const [history] = await pool.query(
'SELECT role,content FROM ai_chat_messages WHERE thread_id=? ORDER BY id ASC LIMIT 40',
[id]
);
const resp = await internalFetch(req, '/premium/ai/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...context, // userProfile, scenarioRow, etc.
chatHistory: history // reuse your existing prompt builder
})
});
const json = await resp.json();
const reply = (json?.reply || '').trim() || 'Sorry, please try again.';
await pool.query(
'INSERT INTO ai_chat_messages (thread_id,user_id,role,content) VALUES (?,?, "assistant", ?)',
[id, req.id, reply]
);
await pool.query('UPDATE ai_chat_threads SET updated_at=CURRENT_TIMESTAMP WHERE id=?', [id]);
res.json({ reply });
});
app.post('/api/premium/career-profile/clone', authenticatePremiumUser, async (req,res) => { app.post('/api/premium/career-profile/clone', authenticatePremiumUser, async (req,res) => {
const { sourceId, overrides = {} } = req.body || {}; const { sourceId, overrides = {} } = req.body || {};
@ -2965,9 +2970,9 @@ app.post('/api/premium/college-profile', authenticatePremiumUser, async (req, re
is_in_state ? 1 : 0, is_in_state ? 1 : 0,
is_in_district ? 1 : 0, is_in_district ? 1 : 0,
college_enrollment_status || null, college_enrollment_status || null,
annual_financial_aid || 0, annual_financial_aid ?? null,
is_online ? 1 : 0, is_online ? 1 : 0,
credit_hours_per_year || 0, credit_hours_per_year ?? null,
hours_completed || 0, hours_completed || 0,
program_length || 0, program_length || 0,
credit_hours_required || 0, credit_hours_required || 0,
@ -3003,7 +3008,8 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res
LIMIT 1 LIMIT 1
`, [req.id, careerProfileId]); `, [req.id, careerProfileId]);
res.json(rows[0] || {}); if (!rows[0]) return res.status(404).json({ error: 'No college profile for this scenario' });
res.json(rows[0]);
} catch (error) { } catch (error) {
console.error('Error fetching college profile:', error); console.error('Error fetching college profile:', error);
res.status(500).json({ error: 'Failed to fetch college profile.' }); res.status(500).json({ error: 'Failed to fetch college profile.' });
@ -3017,7 +3023,9 @@ app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req,
DATE_FORMAT(cp.created_at,'%Y-%m-%d') AS created_at, DATE_FORMAT(cp.created_at,'%Y-%m-%d') AS created_at,
IFNULL(cpr.scenario_title, cpr.career_name) AS career_title IFNULL(cpr.scenario_title, cpr.career_name) AS career_title
FROM college_profiles cp FROM college_profiles cp
JOIN career_profiles cpr ON cpr.id = cp.career_profile_id JOIN career_profiles cpr
ON cpr.id = cp.career_profile_id
AND cpr.user_id = cp.user_id
WHERE cp.user_id = ? WHERE cp.user_id = ?
ORDER BY cp.created_at DESC ORDER BY cp.created_at DESC
`; `;
@ -4090,11 +4098,11 @@ app.post('/api/premium/reminders', authenticatePremiumUser, async (req, res) =>
app.post('/api/premium/stripe/create-checkout-session', app.post('/api/premium/stripe/create-checkout-session',
authenticatePremiumUser, authenticatePremiumUser,
async (req, res) => { async (req, res) => {
const { tier = 'premium', cycle = 'monthly', success_url, cancel_url } = try {
req.body || {}; const { tier = 'premium', cycle = 'monthly', success_url, cancel_url } = req.body || {};
const priceId = priceMap?.[tier]?.[cycle]; const priceId = priceMap?.[tier]?.[cycle];
if (!priceId) return res.status(400).json({ error: 'Bad tier or cycle' }); if (!priceId) return res.status(400).json({ error: 'bad_tier_or_cycle' });
const customerId = await getOrCreateStripeCustomerId(req); const customerId = await getOrCreateStripeCustomerId(req);
@ -4102,10 +4110,8 @@ app.post('/api/premium/stripe/create-checkout-session',
const defaultSuccess = `${base}/billing?ck=success`; const defaultSuccess = `${base}/billing?ck=success`;
const defaultCancel = `${base}/billing?ck=cancel`; const defaultCancel = `${base}/billing?ck=cancel`;
const safeSuccess = success_url && isSafeRedirect(success_url) const safeSuccess = success_url && isSafeRedirect(success_url) ? success_url : defaultSuccess;
? success_url : defaultSuccess; const safeCancel = cancel_url && isSafeRedirect(cancel_url) ? cancel_url : defaultCancel;
const safeCancel = cancel_url && isSafeRedirect(cancel_url)
? cancel_url : defaultCancel;
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
mode : 'subscription', mode : 'subscription',
@ -4116,28 +4122,42 @@ app.post('/api/premium/stripe/create-checkout-session',
cancel_url : safeCancel cancel_url : safeCancel
}); });
res.json({ url: session.url }); return res.json({ url: session.url });
} catch (err) {
console.error('create-checkout-session failed:', err?.raw?.message || err);
return res
.status(err?.statusCode || 500)
.json({ error: 'checkout_failed', message: err?.raw?.message || 'Internal error' });
}
} }
); );
app.get('/api/premium/stripe/customer-portal', app.get('/api/premium/stripe/customer-portal',
authenticatePremiumUser, authenticatePremiumUser,
async (req, res) => { async (req, res) => {
try {
const base = PUBLIC_BASE || `https://${req.headers.host}`; const base = PUBLIC_BASE || `https://${req.headers.host}`;
const { return_url } = req.query; const { return_url } = req.query;
const safeReturn = return_url && isSafeRedirect(return_url) const safeReturn = return_url && isSafeRedirect(return_url) ? return_url : `${base}/billing`;
? return_url
: `${base}/billing`; const cid = await getOrCreateStripeCustomerId(req);
const cid = await getOrCreateStripeCustomerId(req); // never null now
const portal = await stripe.billingPortal.sessions.create({ const portal = await stripe.billingPortal.sessions.create({
customer : cid, customer : cid,
return_url return_url : safeReturn
}); });
res.json({ url: portal.url });
return res.json({ url: portal.url });
} catch (err) {
console.error('customer-portal failed:', err?.raw?.message || err);
return res
.status(err?.statusCode || 500)
.json({ error: 'portal_failed', message: err?.raw?.message || 'Internal error' });
}
} }
); );
app.get('/api/ai-risk/:socCode', async (req, res) => { app.get('/api/ai-risk/:socCode', async (req, res) => {
const { socCode } = req.params; const { socCode } = req.params;
try { try {

View File

@ -2,15 +2,41 @@
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import pool from '../config/mysqlPool.js'; import pool from '../config/mysqlPool.js';
const { JWT_SECRET, TOKEN_MAX_AGE_MS } = process.env; const {
JWT_SECRET,
TOKEN_MAX_AGE_MS,
SESSION_COOKIE_NAME
} = process.env;
const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled 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 === COOKIE_NAME) return decodeURIComponent(rest.join('='));
}
return null;
}
export async function requireAuth(req, res, next) { export async function requireAuth(req, res, next) {
try { try {
// 1) Try Bearer (legacy) then cookie (current)
const authz = req.headers.authorization || ''; const authz = req.headers.authorization || '';
const token = authz.startsWith('Bearer ') ? authz.slice(7) : ''; let token =
authz.startsWith('Bearer ')
? authz.slice(7)
: readSessionCookie(req);
if (!token) return res.status(401).json({ error: 'Auth required' }); if (!token) return res.status(401).json({ error: 'Auth required' });
// 2) Verify JWT
let payload; let payload;
try { payload = jwt.verify(token, JWT_SECRET); } try { payload = jwt.verify(token, JWT_SECRET); }
catch { return res.status(401).json({ error: 'Invalid or expired token' }); } catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
@ -18,18 +44,22 @@ export async function requireAuth(req, res, next) {
const userId = payload.id; const userId = payload.id;
const iatMs = (payload.iat || 0) * 1000; const iatMs = (payload.iat || 0) * 1000;
// Absolute max token age (optional, off by default) // 3) Absolute max token age (optional)
if (MAX_AGE && Date.now() - iatMs > MAX_AGE) { if (MAX_AGE && Date.now() - iatMs > MAX_AGE) {
return res.status(401).json({ error: 'Session expired. Please sign in again.' }); return res.status(401).json({ error: 'Session expired. Please sign in again.' });
} }
// Reject tokens issued before last password change // 4) Invalidate tokens issued before last password change
const [rows] = await (pool.raw || pool).query( 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', 'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId] [userId]
); );
const changedAt = rows?.[0]?.password_changed_at || 0; const changedAtMs = rows?.[0]?.password_changed_at
if (changedAt && iatMs < changedAt) { ? 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.' }); return res.status(401).json({ error: 'Session invalidated. Please sign in again.' });
} }

View File

@ -1,19 +1,28 @@
import jwt from "jsonwebtoken"; import jwt from "jsonwebtoken";
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
const COOKIE_NAME = process.env.COOKIE_NAME || 'aptiva_session';
/** /**
* Adds `req.user = { id: <user_profile.id> }` * Adds `req.user = { id }`
* If no or bad token 401. * Accepts either Bearer token or httpOnly cookie.
* 401 on missing; 401 again on invalid/expired.
*/ */
export default function authenticateUser(req, res, next) { export default function authenticateUser(req, res, next) {
const token = req.headers.authorization?.split(" ")[1]; let token = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.split(' ')[1]
: null;
if (!token) {
token = req.cookies?.[COOKIE_NAME] || req.cookies?.token || null;
}
if (!token) return res.status(401).json({ error: "Authorization token required" }); if (!token) return res.status(401).json({ error: "Authorization token required" });
try { try {
const { id } = jwt.verify(token, JWT_SECRET); const { id } = jwt.verify(token, JWT_SECRET);
req.user = { id }; // attach the id for downstream use req.user = { id };
next(); next();
} catch (err) { } catch {
return res.status(401).json({ error: "Invalid or expired token" }); return res.status(401).json({ error: "Invalid or expired token" });
} }
} }

View File

@ -0,0 +1,26 @@
// src/backend/utils/onboardingDraftApi.js
import authFetch from '../../utils/authFetch.js';
const DRAFT_URL = '/api/premium/onboarding/draft';
export async function loadDraft() {
const res = await authFetch(DRAFT_URL);
if (res.status === 404) return null;
if (!res.ok) throw new Error(`loadDraft failed: ${res.status}`);
return res.json(); // -> { id, step, data } | null
}
export async function saveDraft({ id, step = 0, data = {} }) {
const res = await authFetch(DRAFT_URL, {
method: 'POST',
body: JSON.stringify({ id, step, data })
});
if (!res.ok) throw new Error(`saveDraft failed: ${res.status}`);
return res.json(); // -> { id, step }
}
export async function clearDraft() {
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
if (!res.ok) throw new Error(`clearDraft failed: ${res.status}`);
return res.json(); // -> { ok: true }
}

View File

@ -1,71 +1,119 @@
# ───────────────────────── config ─────────────────────────
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail # fail fast, surfacing missing vars set -euo pipefail
# Accept priority: 1) CLI arg 2) exported variable 3) default 'dev' # ───────────────────────── config ─────────────────────────
ENV="${1:-${ENV:-dev}}" ENV="${1:-${ENV:-dev}}"
case "$ENV" in dev|staging|prod) ;; *) echo "❌ Unknown ENV='$ENV'"; exit 1 ;; esac
case "$ENV" in dev|staging|prod) ;; # sanity guard PROJECT="aptivaai-${ENV}"
*) echo "❌ Unknown ENV='$ENV'"; exit 1 ;;
esac
PROJECT="aptivaai-${ENV}" # adjust if prod lives elsewhere
REG="us-central1-docker.pkg.dev/${PROJECT}/aptiva-repo" REG="us-central1-docker.pkg.dev/${PROJECT}/aptiva-repo"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ROOT}/.env" ENV_FILE="${ROOT}/.env"
echo "🔧 Deploying environment: $ENV (GCP: $PROJECT)" echo "🔧 Deploying environment: $ENV (GCP: $PROJECT)"
SECRETS=( SECRETS=(
ENV_NAME PROJECT CORS_ALLOWED_ORIGINS
SERVER1_PORT SERVER2_PORT SERVER3_PORT
JWT_SECRET OPENAI_API_KEY ONET_USERNAME ONET_PASSWORD JWT_SECRET OPENAI_API_KEY ONET_USERNAME ONET_PASSWORD
STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY STRIPE_WH_SECRET \ STRIPE_SECRET_KEY STRIPE_PUBLISHABLE_KEY STRIPE_WH_SECRET
STRIPE_PRICE_PREMIUM_MONTH STRIPE_PRICE_PREMIUM_YEAR \ STRIPE_PRICE_PREMIUM_MONTH STRIPE_PRICE_PREMIUM_YEAR
STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR \ STRIPE_PRICE_PRO_MONTH STRIPE_PRICE_PRO_YEAR
DB_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD \ DB_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \ DB_SSL_CERT DB_SSL_KEY DB_SSL_CA
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \ SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \ TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID
GOOGLE_MAPS_API_KEY
KMS_KEY_NAME DEK_PATH KMS_KEY_NAME DEK_PATH
) )
cd "$ROOT" cd "$ROOT"
echo "🛠 Building frontend bundle"
npm ci --silent
npm run build
# ───────────────────── build & push images ───────────────────── # ───────────── pull runtime secrets (BEFORE build) ─────────────
TAG="$(git rev-parse --short HEAD)-$(date -u +%Y%m%d%H%M)"
echo "🔨 Building & pushing containers (tag = ${TAG})"
for svc in server1 server2 server3 nginx; do
docker build -f "Dockerfile.${svc}" -t "${REG}/${svc}:${TAG}" .
docker push "${REG}/${svc}:${TAG}"
done
if grep -q '^IMG_TAG=' "$ENV_FILE"; then
sed -i "s/^IMG_TAG=.*/IMG_TAG=${TAG}/" "$ENV_FILE"
else
echo "IMG_TAG=${TAG}" >> "$ENV_FILE"
fi
echo "✅ .env updated with IMG_TAG=${TAG}"
# ─────────────────────────────────────────────────────────────
# 1a. Publish IMG_TAG to Secret Manager (single source of truth)
# ─────────────────────────────────────────────────────────────
printf "%s" "${TAG}" | gcloud secrets versions add IMG_TAG --data-file=- --project="$PROJECT"
echo "📦 IMG_TAG pushed to Secret Manager (no suffix)"
# ───────────────────── pull secrets (incl. KMS key path) ───────
echo "🔐 Pulling secrets from Secret Manager" echo "🔐 Pulling secrets from Secret Manager"
for S in "${SECRETS[@]}"; do for S in "${SECRETS[@]}"; do
export "$S"="$(gcloud secrets versions access latest \ export "$S"="$(gcloud secrets versions access latest --secret="${S}_${ENV}" --project="$PROJECT")"
--secret="${S}_${ENV}" --project="$PROJECT")"
done done
export FROM_SECRETS_MANAGER=true export FROM_SECRETS_MANAGER=true
# ───────────────────── compose up ─────────────────────────────── # React needs the prefixed var at BUILD time
preserve=IMG_TAG,FROM_SECRETS_MANAGER,REACT_APP_API_URL,$(IFS=,; echo "${SECRETS[*]}") export REACT_APP_GOOGLE_MAPS_API_KEY="$GOOGLE_MAPS_API_KEY"
# ───────────────────────── node + npm ci cache ─────────────────────────
echo "🛠 Building front-end bundle (skips when unchanged)"
export npm_config_cache="${HOME}/.npm" # persist npm cache
export CI=false # dont treat warnings as errors
NODE_VER="$(node -v 2>/dev/null || echo 'none')"
if [[ ! -f .last-node || "$(cat .last-node 2>/dev/null || echo)" != "$NODE_VER" ]]; then
echo "♻️ Node changed → cleaning node_modules (was '$(cat .last-node 2>/dev/null || echo none)', now '${NODE_VER}')"
rm -rf node_modules .build.hash
fi
echo "$NODE_VER" > .last-node
if [[ ! -f package-lock.json ]]; then
echo "⚠️ package-lock.json missing; running npm ci"
npm ci --silent --no-audit --no-fund
else
LOCK_HASH="$(sha1sum package-lock.json | awk '{print $1}')"
if [[ -d node_modules && -f .last-lock && "$(cat .last-lock)" == "$LOCK_HASH" ]]; then
echo "📦 node_modules up-to-date; skipping npm ci"
else
echo "📦 installing deps…"
npm ci --silent --no-audit --no-fund
echo "$LOCK_HASH" > .last-lock
echo "$LOCK_HASH" > .lock.hash # legacy compat
fi
fi
# ───────────────────────── npm run build cache ─────────────────────────
SRC_HASH="$(find src public -type f -print0 2>/dev/null | sort -z | xargs -0 sha1sum | sha1sum | awk '{print $1}')"
PKG_HASH="$(sha1sum package.json package-lock.json 2>/dev/null | sha1sum | awk '{print $1}')"
BUILD_ENV_HASH="$(printf '%s' "${REACT_APP_GOOGLE_MAPS_API_KEY}-${REACT_APP_API_URL:-}" | sha1sum | awk '{print $1}')"
COMBINED_HASH="${SRC_HASH}-${PKG_HASH}-${BUILD_ENV_HASH}"
if [[ -f .build.hash && "$(cat .build.hash)" == "$COMBINED_HASH" && -d build ]]; then
echo "🏗 static bundle up-to-date; skipping npm run build"
else
echo "🏗 Building static bundle…"
GENERATE_SOURCEMAP=false NODE_OPTIONS="--max-old-space-size=4096" npm run build
echo "$COMBINED_HASH" > .build.hash
fi
# ───────────────────── build & push images (SEQUENTIAL) ─────────────────────
export DOCKER_BUILDKIT=1
export COMPOSE_DOCKER_CLI_BUILD=1
export BUILDKIT_PROGRESS=plain # stable progress output
TAG="$(git rev-parse --short HEAD)-$(date -u +%Y%m%d%H%M)"
echo "🔨 Building & pushing containers (tag = ${TAG})"
build_and_push () {
local svc="$1"
echo "🧱 Building ${svc}"
docker build --progress=plain -f "Dockerfile.${svc}" -t "${REG}/${svc}:${TAG}" .
echo "⏫ Pushing ${svc}"
docker push "${REG}/${svc}:${TAG}"
}
# Build servers first, then nginx (needs ./build)
for svc in server1 server2 server3 nginx; do
build_and_push "$svc"
done
# ───────────────────── write IMG_TAG locally ─────────────────────
export IMG_TAG="${TAG}"
echo "🔖 Using IMG_TAG=${IMG_TAG} (not writing to .env)"
# ───────────────────── publish IMG_TAG to Secret Manager ─────────────────────
printf "%s" "${TAG}" | gcloud secrets versions add IMG_TAG --data-file=- --project="$PROJECT" >/dev/null
echo "📦 IMG_TAG pushed to Secret Manager"
# ───────────────────── docker compose up ─────────────────────
preserve=IMG_TAG,FROM_SECRETS_MANAGER,REACT_APP_API_URL,REACT_APP_GOOGLE_MAPS_API_KEY,$(IFS=,; echo "${SECRETS[*]}")
echo "🚀 docker compose up -d (env: $preserve)" echo "🚀 docker compose up -d (env: $preserve)"
sudo --preserve-env="$preserve" docker compose up -d --force-recreate \ sudo --preserve-env="$preserve" docker compose up -d --force-recreate \
2> >(grep -v 'WARN \[0000\]') 2> >(grep -v 'WARN \[0000\]')

View File

@ -3,8 +3,6 @@
# Every secret is exported from fetchsecrets.sh and injected at deploy time. # Every secret is exported from fetchsecrets.sh and injected at deploy time.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
x-env: &with-env x-env: &with-env
env_file:
- .env # committed, nonsecret
restart: unless-stopped restart: unless-stopped
services: services:
@ -79,6 +77,7 @@ services:
PROJECT: ${PROJECT} PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME} KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH} DEK_PATH: ${DEK_PATH}
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
ONET_USERNAME: ${ONET_USERNAME} ONET_USERNAME: ${ONET_USERNAME}
ONET_PASSWORD: ${ONET_PASSWORD} ONET_PASSWORD: ${ONET_PASSWORD}
JWT_SECRET: ${JWT_SECRET} JWT_SECRET: ${JWT_SECRET}
@ -169,6 +168,8 @@ services:
command: ["nginx", "-g", "daemon off;"] command: ["nginx", "-g", "daemon off;"]
depends_on: [server1, server2, server3] depends_on: [server1, server2, server3]
networks: [default, aptiva-shared] networks: [default, aptiva-shared]
environment:
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
ports: ["80:80", "443:443"] ports: ["80:80", "443:443"]
volumes: volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro - ./nginx.conf:/etc/nginx/nginx.conf:ro

View File

@ -179,3 +179,41 @@ UPDATE user_auth
SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000) SET hashed_password = ?, password_changed_at = FROM_UNIXTIME(?/1000)
WHERE user_id = ? WHERE user_id = ?
-- MySQL
CREATE TABLE IF NOT EXISTS onboarding_drafts (
user_id BIGINT NOT NULL,
id CHAR(36) NOT NULL,
step TINYINT NOT NULL DEFAULT 0,
data JSON NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (user_id),
UNIQUE KEY uniq_id (id)
);
-- ai_chat_threads: one row per conversation
CREATE TABLE IF NOT EXISTS ai_chat_threads (
id CHAR(36) PRIMARY KEY,
user_id BIGINT NOT NULL,
bot_type ENUM('support','retire','coach') NOT NULL,
title VARCHAR(200) NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX (user_id, bot_type, updated_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ai_chat_messages: ordered messages in a thread
CREATE TABLE IF NOT EXISTS ai_chat_messages (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
thread_id CHAR(36) NOT NULL,
user_id BIGINT NOT NULL,
role ENUM('user','assistant','system') NOT NULL,
content MEDIUMTEXT NOT NULL,
meta_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX (thread_id, created_at),
CONSTRAINT fk_chat_thread
FOREIGN KEY (thread_id) REFERENCES ai_chat_threads(id)
ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -54,7 +54,7 @@ http {
location ^~ /api/tuition/ { proxy_pass http://backend5001; } location ^~ /api/tuition/ { proxy_pass http://backend5001; }
location ^~ /api/projections/ { proxy_pass http://backend5001; } location ^~ /api/projections/ { proxy_pass http://backend5001; }
location ^~ /api/skills/ { proxy_pass http://backend5001; } location ^~ /api/skills/ { proxy_pass http://backend5001; }
location ^~ /api/ai-risk { proxy_pass http://backend5002; } location ^~ /api/ai-risk { proxy_pass http://backend5001; }
location ^~ /api/maps/distance { proxy_pass http://backend5001; } location ^~ /api/maps/distance { proxy_pass http://backend5001; }
location ^~ /api/schools { proxy_pass http://backend5001; } location ^~ /api/schools { proxy_pass http://backend5001; }
location ^~ /api/support { proxy_pass http://backend5001; } location ^~ /api/support { proxy_pass http://backend5001; }

23
package-lock.json generated
View File

@ -25,6 +25,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cra-template": "1.2.0", "cra-template": "1.2.0",
"docx": "^9.5.0", "docx": "^9.5.0",
@ -7328,6 +7329,28 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": { "node_modules/cookie-signature": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",

View File

@ -20,6 +20,7 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"cra-template": "1.2.0", "cra-template": "1.2.0",
"docx": "^9.5.0", "docx": "^9.5.0",

View File

@ -41,8 +41,9 @@ import BillingResult from './components/BillingResult.js';
import SupportModal from './components/SupportModal.js'; import SupportModal from './components/SupportModal.js';
import ForgotPassword from './components/ForgotPassword.js'; import ForgotPassword from './components/ForgotPassword.js';
import ResetPassword from './components/ResetPassword.js'; import ResetPassword from './components/ResetPassword.js';
import { clearToken } from '../auth/authMemory.js'; import { clearToken } from './auth/authMemory.js';
import api from './auth/apiClient.js';
import * as safeLocal from './utils/safeLocal.js';
@ -51,12 +52,8 @@ export const ProfileCtx = React.createContext();
function ResetPasswordGate() { function ResetPasswordGate() {
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
try { clearToken();
localStorage.removeItem('token'); try { localStorage.removeItem('id'); } catch {}
localStorage.removeItem('id');
// If you cache other auth-ish flags, clear them here too
} catch {}
// no navigate here; we want to render the reset UI
}, [location.pathname]); }, [location.pathname]);
return <ResetPassword />; return <ResetPassword />;
@ -165,6 +162,54 @@ const showPremiumCTA = !premiumPaths.some(p =>
} }
} }
// ==============================
// 1) Single Rehydrate UseEffect
// ==============================
useEffect(() => {
let cancelled = false;
// Dont do auth probe on reset-password
if (location.pathname.startsWith('/reset-password')) {
try { localStorage.removeItem('id'); } catch {}
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
(async () => {
setIsLoading(true);
try {
// axios client already: withCredentials + Bearer from authMemory
const { data } = await api.get('/api/user-profile');
if (cancelled) return;
setUser(data);
setIsAuthenticated(true);
} catch (err) {
if (cancelled) return;
clearToken();
setIsAuthenticated(false);
setUser(null);
// Only kick to /signin if youre not already on a public page
const p = location.pathname;
const onPublic =
p === '/signin' ||
p === '/signup' ||
p === '/forgot-password' ||
p.startsWith('/reset-password') ||
p === '/paywall';
if (!onPublic) navigate('/signin?session=expired', { replace: true });
} finally {
if (!cancelled) setIsLoading(false);
}
})();
return () => { cancelled = true; };
// include isAuthScreen if you prefer, but this local check avoids a dep loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, navigate]);
/* ===================== /* =====================
Support Modal Email Support Modal Email
===================== */ ===================== */
@ -172,57 +217,6 @@ const showPremiumCTA = !premiumPaths.some(p =>
setUserEmail(user?.email || ''); setUserEmail(user?.email || '');
}, [user]); }, [user]);
// ==============================
// 1) Single Rehydrate UseEffect
// ==============================
useEffect(() => {
// 🚫 Never hydrate auth while on the reset page
if (location.pathname.startsWith('/reset-password')) {
try {
localStorage.removeItem('token');
localStorage.removeItem('id');
} catch {}
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
const token = localStorage.getItem('token');
if (!token) {
// No token => not authenticated
setIsLoading(false);
return;
}
// If we have a token, validate it by fetching user
fetch('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
if (!res.ok) throw new Error('Token invalid on server side');
return res.json();
})
.then((profile) => {
// Successfully got user profile => user is authenticated
setUser(profile);
setIsAuthenticated(true);
})
.catch((err) => {
console.error(err);
// Invalid token => remove it, force sign in
localStorage.removeItem('token');
setIsAuthenticated(false);
setUser(null);
navigate('/signin?session=expired');
})
.finally(() => {
// Either success or fail, we're done loading
setIsLoading(false);
});
}, [navigate, location.pathname]);
// ========================== // ==========================
// 2) Logout Handler + Modal // 2) Logout Handler + Modal
@ -239,36 +233,47 @@ const showPremiumCTA = !premiumPaths.some(p =>
const confirmLogout = () => { const confirmLogout = async () => {
localStorage.removeItem('token'); // 1) Ask the server to clear the session cookie
localStorage.removeItem('id'); try {
localStorage.removeItem('careerSuggestionsCache'); // If you created /logout (no /api prefix):
localStorage.removeItem('lastSelectedCareerProfileId'); await api.post('/logout'); // axios client is withCredentials: true
localStorage.removeItem('selectedCareer'); // If your route is /api/signout instead, use:
localStorage.removeItem('aiClickCount'); // await api.post('/api/signout');
localStorage.removeItem('aiClickDate'); } catch (e) {
localStorage.removeItem('aiRecommendations'); console.warn('Server logout failed (continuing client-side):', e?.message || e);
localStorage.removeItem('premiumOnboardingState'); // ← NEW }
localStorage.removeItem('financialProfile'); // ← if you cache it
setFinancialProfile(null); // ← reset any React-context copy // 2) Clear client-side state/caches
clearToken(); // in-memory bearer (if any, for legacy flows)
safeLocal.clearMany([
'id',
'careerSuggestionsCache',
'lastSelectedCareerProfileId',
'selectedCareer',
'aiClickCount',
'aiClickDate',
'aiRecommendations',
'premiumOnboardingState',
'financialProfile',
'selectedScenario',
]);
// 3) Reset React state
setFinancialProfile(null);
setScenario(null); setScenario(null);
setIsAuthenticated(false); setIsAuthenticated(false);
setUser(null); setUser(null);
setShowLogoutWarning(false); setShowLogoutWarning(false);
// Reset auth // 4) Back to sign-in
setIsAuthenticated(false); navigate('/signin', { replace: true });
setUser(null);
setShowLogoutWarning(false);
navigate('/signin');
}; };
const cancelLogout = () => {
const cancelLogout = () => {
setShowLogoutWarning(false); setShowLogoutWarning(false);
}; };
// ==================================== // ====================================
// 3) If still verifying the token, show loading // 3) If still verifying the token, show loading

38
src/auth/apiClient.js Normal file
View File

@ -0,0 +1,38 @@
// src/apiClient.js
import axios from 'axios';
import { getToken, clearToken } from './authMemory.js';
// sane defaults
axios.defaults.withCredentials = true; // send cookies to same-origin /api when needed
axios.defaults.timeout = 20000;
// attach Authorization from in-memory token
axios.interceptors.request.use((config) => {
const t = getToken();
if (t) {
config.headers = config.headers || {};
if (!config.headers.Authorization) {
config.headers.Authorization = `Bearer ${t}`;
}
}
return config;
});
// central 401 handling (optional)
axios.interceptors.response.use(
r => r,
(err) => {
const status = err?.response?.status;
if (status === 401) {
clearToken();
// ping your SessionExpiredHandler (you already mount it)
window.dispatchEvent(new CustomEvent('aptiva:session-expired'));
}
return Promise.reject(err);
}
);
export default axios; // optional; callers can still `import axios from "axios"`
export const api = axios;

View File

@ -1,9 +1,15 @@
// apiFetch.js // src/auth/apiFetch.js
import { getToken } from './authMemory.js'; import { getToken, clearToken } from './authMemory.js';
export async function apiFetch(input, init = {}) { export default function apiFetch(input, init = {}) {
const headers = new Headers(init.headers || {}); const headers = new Headers(init.headers || {});
const t = getToken(); // optional: add Bearer if you *happen* to have one in memory
const t = window.__auth?.get?.();
if (t) headers.set('Authorization', `Bearer ${t}`); if (t) headers.set('Authorization', `Bearer ${t}`);
return fetch(input, { ...init, headers });
return fetch(input, {
...init,
headers,
credentials: 'include' // ← send cookie
});
} }

View File

@ -1,14 +1,24 @@
// authMemory.js // authMemory.js
let accessToken = ''; let accessToken = '';
let expiresAt = 0; // ms epoch (optional) let expiresAt = 0;
let timer;
export function setToken(token, expiresInSec) { export function setToken(token, ttlSec) {
accessToken = token || ''; accessToken = token || '';
expiresAt = token && expiresInSec ? Date.now() + expiresInSec * 1000 : 0; expiresAt = token && ttlSec ? Date.now() + ttlSec * 1000 : 0;
if (timer) clearTimeout(timer);
if (expiresAt) {
timer = setTimeout(() => { accessToken=''; expiresAt=0; timer=null; }, Math.max(0, expiresAt - Date.now()));
}
} }
export function clearToken() { accessToken = ''; expiresAt = 0; }
export function clearToken() {
accessToken = ''; expiresAt = 0;
if (timer) { clearTimeout(timer); timer = null; }
}
export function getToken() { export function getToken() {
if (!accessToken) return ''; if (!accessToken) return '';
if (expiresAt && Date.now() > expiresAt) return ''; if (expiresAt && Date.now() > expiresAt) { clearToken(); return ''; }
return accessToken; return accessToken;
} }

View File

@ -1,7 +1,8 @@
import { useEffect, useState, useContext } from 'react'; import { useEffect, useState, useContext } from 'react';
import { useLocation, Link } from 'react-router-dom'; import { useLocation, Link } from 'react-router-dom';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import { ProfileCtx } from '../App.js'; // <- exported at very top of App.jsx import { ProfileCtx } from '../App.js';
import api from '../auth/apiClient.js';
export default function BillingResult() { export default function BillingResult() {
const { setUser } = useContext(ProfileCtx) || {}; const { setUser } = useContext(ProfileCtx) || {};
@ -11,14 +12,22 @@ export default function BillingResult() {
/* /*
1) Ask the API for the latest user profile (flags, etc.) 1) Ask the API for the latest user profile (flags, etc.)
will be fast because JWT is already cached cookies + in-mem token handled by apiClient
*/ */
useEffect(() => { useEffect(() => {
const token = localStorage.getItem('token') || ''; let cancelled = false;
fetch('/api/user-profile', { headers: { Authorization: `Bearer ${token}` } }) (async () => {
.then(r => r.ok ? r.json() : null) try {
.then(profile => { if (profile && setUser) setUser(profile); }) const { data } = await api.get('/api/user-profile');
.finally(() => setLoading(false)); if (!cancelled && data && setUser) setUser(data);
} catch (err) {
// 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]); }, [setUser]);
/* /*

View File

@ -119,6 +119,7 @@ export default function CareerCoach({
const [showGoals , setShowGoals ] = useState(false); const [showGoals , setShowGoals ] = useState(false);
const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || ""); const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || "");
const [saving , setSaving ] = useState(false); const [saving , setSaving ] = useState(false);
const [threadId, setThreadId] = useState(null);
/* -------------- scroll --------------- */ /* -------------- scroll --------------- */
useEffect(() => { useEffect(() => {
@ -135,6 +136,31 @@ useEffect(() => {
localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20))); localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20)));
}, [messages, careerProfileId]); }, [messages, careerProfileId]);
useEffect(() => {
(async () => {
if (!careerProfileId) return;
// try to reuse the newest coach thread; create one named after the scenario
const r = await authFetch('/api/premium/coach/chat/threads');
const { threads = [] } = await r.json();
const existing = threads.find(Boolean);
let id = existing?.id;
if (!id) {
const r2 = await authFetch('/api/premium/coach/chat/threads', {
method:'POST',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ title: (scenarioRow?.scenario_title || scenarioRow?.career_name || 'Coach chat') })
});
({ id } = await r2.json());
}
setThreadId(id);
// preload history
const r3 = await authFetch(`/api/premium/coach/chat/threads/${id}`);
const { messages: msgs = [] } = await r3.json();
setMessages(msgs);
})();
}, [careerProfileId]);
/* -------------- intro ---------------- */ /* -------------- intro ---------------- */
useEffect(() => { useEffect(() => {
if (!scenarioRow) return; if (!scenarioRow) return;
@ -208,48 +234,23 @@ I'm here to support you with personalized coaching. What would you like to focus
async function callAi(updatedHistory, opts = {}) { async function callAi(updatedHistory, opts = {}) {
setLoading(true); setLoading(true);
try { try {
const payload = { const context = { userProfile, financialProfile, scenarioRow, collegeProfile };
userProfile, const r = await authFetch(`/api/premium/coach/chat/threads/${threadId}/messages`, {
financialProfile, method:'POST',
scenarioRow, headers:{ 'Content-Type':'application/json' },
collegeProfile, body: JSON.stringify({ content: updatedHistory.at(-1)?.content || '', context })
chatHistory: updatedHistory.slice(-10),
...opts
};
const res = await authFetch("/api/premium/ai/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
}); });
const data = await res.json(); // might only have .error const data = await r.json();
const replyRaw = data?.reply ?? ""; // always a string const reply = (data?.reply || '').trim() || 'Sorry, something went wrong.';
const riskData = data?.aiRisk; setMessages(prev => [...prev, { role:'assistant', content: reply }]);
const createdMilestones = data?.createdMilestones ?? []; } catch (e) {
console.error(e);
// guard empty or non-string → generic apology setMessages(prev => [...prev, { role:'assistant', content:'Sorry, something went wrong.' }]);
const safeReply = typeof replyRaw === "string" && replyRaw.trim()
? replyRaw
: "Sorry, something went wrong on the server.";
// If GPT accidentally returned raw JSON, hide it from user
const isJson = safeReply.trim().startsWith("{") || safeReply.trim().startsWith("[");
const friendlyReply = isJson
? "✅ Got it! I added new milestones to your plan. Check your Milestones tab."
: safeReply;
setMessages((prev) => [...prev, { role: "assistant", content: friendlyReply }]);
if (riskData && onAiRiskFetched) onAiRiskFetched(riskData);
if (createdMilestones.length && typeof onMilestonesCreated === 'function') {
onMilestonesCreated(); // no arg needed just refetch
}
} catch (err) {
console.error(err);
setMessages((prev) => [...prev, { role: "assistant", content: "Sorry, something went wrong." }]);
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
/* ------------ normal send ------------- */ /* ------------ normal send ------------- */
function handleSubmit(e) { function handleSubmit(e) {

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react'; import React, { useState, useEffect, useMemo, useCallback, useContext } from 'react';
import { useNavigate, useLocation, createSearchParams } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import ChatCtx from '../contexts/ChatCtx.js'; import ChatCtx from '../contexts/ChatCtx.js';
import CareerSuggestions from './CareerSuggestions.js'; import CareerSuggestions from './CareerSuggestions.js';
@ -8,8 +8,9 @@ import CareerModal from './CareerModal.js';
import InterestMeaningModal from './InterestMeaningModal.js'; import InterestMeaningModal from './InterestMeaningModal.js';
import CareerSearch from './CareerSearch.js'; import CareerSearch from './CareerSearch.js';
import { Button } from './ui/button.js'; import { Button } from './ui/button.js';
import axios from 'axios'; import apiFetch from '../auth/apiFetch.js';
import isAllOther from '../utils/isAllOther.js'; import api from '../auth/apiClient.js';
const STATES = [ const STATES = [
{ name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' }, { name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' },
@ -82,7 +83,7 @@ function CareerExplorer() {
defaultMeaning: 3, defaultMeaning: 3,
}); });
// ...
const fitRatingMap = { const fitRatingMap = {
Best: 5, Best: 5,
Great: 4, Great: 4,
@ -170,7 +171,7 @@ function CareerExplorer() {
setProgress(0); setProgress(0);
// 1) O*NET answers -> initial career list // 1) O*NET answers -> initial career list
const submitRes = await axios.post('/api/onet/submit_answers', { const submitRes = await api.post('/api/onet/submit_answers', {
answers, answers,
state: profileData.state, state: profileData.state,
area: profileData.area, area: profileData.area,
@ -193,7 +194,7 @@ function CareerExplorer() {
// A helper that does a GET request, increments progress on success/fail // A helper that does a GET request, increments progress on success/fail
const fetchWithProgress = async (url, params) => { const fetchWithProgress = async (url, params) => {
try { try {
const res = await axios.get(url, { params }); const res = await api.get(url, { params });
increment(); increment();
return res.data; return res.data;
} catch (err) { } catch (err) {
@ -204,7 +205,7 @@ function CareerExplorer() {
// 2) job zones (one call for all SOC codes) // 2) job zones (one call for all SOC codes)
const socCodes = flattened.map((c) => c.code); const socCodes = flattened.map((c) => c.code);
const zonesRes = await axios.post('/api/job-zones', { socCodes }).catch(() => null); const zonesRes = await api.post('/api/job-zones', { socCodes }).catch(() => null);
// increment progress for this single request // increment progress for this single request
increment(); increment();
@ -246,7 +247,7 @@ function CareerExplorer() {
return { return {
...career, ...career,
job_zone: jobZoneData[career.code.slice(0, -3)]?.job_zone || null, job_zone: jobZoneData[stripSoc(career.code)]?.job_zone || null,
limitedData: isLimitedData, limitedData: isLimitedData,
}; };
}); });
@ -291,10 +292,7 @@ function CareerExplorer() {
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
try { try {
const token = localStorage.getItem('token'); const res = await api.get('/api/user-profile');
const res = await axios.get('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.status === 200) { if (res.status === 200) {
const profileData = res.data; const profileData = res.data;
@ -356,26 +354,21 @@ const handleCareerClick = useCallback(
/* ---------- 1. CIP lookup ---------- */ /* ---------- 1. CIP lookup ---------- */
let cipCode = null; let cipCode = null;
try { try {
const cipRes = await fetch(`/api/cip/${socCode}`); const { data } = await api.get(`/api/cip/${socCode}`);
if (cipRes.ok) { cipCode = data?.cipCode ?? null;
cipCode = (await cipRes.json()).cipCode ?? null;
}
} catch { /* swallow */ } } catch { /* swallow */ }
/* ---------- 2. Job description & tasks ---------- */ /* ---------- 2. Job description & tasks ---------- */
let description = ''; let description = '';
let tasks = []; let tasks = [];
try { try {
const jobRes = await fetch(`/api/onet/career-description/${socCode}`); const { data: jd } = await api.get(`/api/onet/career-description/${socCode}`);
if (jobRes.ok) { description = jd?.description ?? '';
const jd = await jobRes.json(); tasks = jd?.tasks ?? [];
description = jd.description ?? '';
tasks = jd.tasks ?? [];
}
} catch { /* swallow */ } } catch { /* swallow */ }
/* ---------- 3. Salary data ---------- */ /* ---------- 3. Salary data ---------- */
const salaryRes = await axios const salaryRes = await api
.get('/api/salary', { .get('/api/salary', {
params: { socCode: socCode.split('.')[0], area: areaTitle }, params: { socCode: socCode.split('.')[0], area: areaTitle },
}) })
@ -394,7 +387,7 @@ const handleCareerClick = useCallback(
/* ---------- 4. Economic projections ---------- */ /* ---------- 4. Economic projections ---------- */
const fullStateName = getFullStateName(userState); const fullStateName = getFullStateName(userState);
const projRes = await axios const projRes = await api
.get(`/api/projections/${socCode.split('.')[0]}`, { .get(`/api/projections/${socCode.split('.')[0]}`, {
params: { state: fullStateName }, params: { state: fullStateName },
}) })
@ -416,11 +409,11 @@ const handleCareerClick = useCallback(
let aiRisk = null; let aiRisk = null;
if (haveJobInfo) { if (haveJobInfo) {
try { try {
aiRisk = (await axios.get(`/api/ai-risk/${socCode}`)).data; aiRisk = (await api.get(`/api/ai-risk/${socCode}`)).data;
} catch (err) { } catch (err) {
if (err.response?.status === 404) { if (err.response?.status === 404) {
try { try {
const aiRes = await axios.post('/api/public/ai-risk-analysis', { const aiRes = await api.post('/api/public/ai-risk-analysis', {
socCode, socCode,
careerName: career.title, careerName: career.title,
jobDescription: description, jobDescription: description,
@ -428,7 +421,7 @@ const handleCareerClick = useCallback(
}); });
aiRisk = aiRes.data; aiRisk = aiRes.data;
// cache for next time (besteffort) // cache for next time (besteffort)
axios.post('/api/ai-risk', aiRisk).catch(() => {}); api.post('/api/ai-risk', aiRisk).catch(() => {});
} catch { /* GPT fallback failed ignore */ } } catch { /* GPT fallback failed ignore */ }
} }
} }
@ -574,8 +567,7 @@ useEffect(() => {
// ------------------------------------------------------ // ------------------------------------------------------
const saveCareerListToBackend = async (newCareerList) => { const saveCareerListToBackend = async (newCareerList) => {
try { try {
const token = localStorage.getItem('token'); await api.post(
await axios.post(
'/api/user-profile', '/api/user-profile',
{ {
firstName: userProfile?.firstname, firstName: userProfile?.firstname,
@ -589,9 +581,6 @@ useEffect(() => {
career_priorities: userProfile?.career_priorities, career_priorities: userProfile?.career_priorities,
career_list: JSON.stringify(newCareerList), career_list: JSON.stringify(newCareerList),
}, },
{
headers: { Authorization: `Bearer ${token}` },
}
); );
} catch (err) { } catch (err) {
console.error('Error saving career_list:', err); console.error('Error saving career_list:', err);
@ -720,13 +709,11 @@ const handleSelectForEducation = async (career) => {
let fromApi = null; let fromApi = null;
for (const soc of candidates) { for (const soc of candidates) {
const res = await fetch(`/api/cip/${soc}`); try {
if (res.ok) { const { data } = await api.get(`/api/cip/${soc}`);
const { cipCode } = await res.json(); if (data?.cipCode) { fromApi = data.cipCode; break; }
if (cipCode) { fromApi = cipCode; break; } } catch {}
} }
}
if (fromApi) { if (fromApi) {
rawCips = [fromApi]; rawCips = [fromApi];
cleanedCips = cleanCipCodes(rawCips); cleanedCips = cleanCipCodes(rawCips);

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import isAllOther from '../utils/isAllOther.js'; import isAllOther from '../utils/isAllOther.js';
@ -34,9 +33,9 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
); );
} }
if (!careerDetails?.salaryData === undefined) { if (!careerDetails || careerDetails.salaryData === undefined) {
return ( return (
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center z-50"> <div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50" role="dialog" aria-modal="true">
<div className="bg-white rounded-lg shadow-lg p-6"> <div className="bg-white rounded-lg shadow-lg p-6">
<p className="text-lg text-gray-700">Loading career details...</p> <p className="text-lg text-gray-700">Loading career details...</p>
</div> </div>
@ -61,7 +60,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
return ( return (
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50"> <div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50" role="dialog" aria-modal="true">
<div className="bg-white rounded-lg shadow-lg w-full max-w-5xl p-6 m-4 max-h-[90vh] overflow-y-auto"> <div className="bg-white rounded-lg shadow-lg w-full max-w-5xl p-6 m-4 max-h-[90vh] overflow-y-auto">
@ -88,11 +87,8 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</h2> </h2>
{/* AI RISK SECTION */} {/* AI RISK SECTION */}
{loadingRisk && (
<p className="text-sm text-gray-500 mt-1">Loading AI risk</p>
)}
{!loadingRisk && aiRisk && aiRisk.riskLevel && aiRisk.reasoning && ( {aiRisk && aiRisk.riskLevel && aiRisk.reasoning && (
<div className="text-sm text-gray-500 mt-1"> <div className="text-sm text-gray-500 mt-1">
<strong>AI Risk Level:</strong> {aiRisk.riskLevel} <strong>AI Risk Level:</strong> {aiRisk.riskLevel}
<br /> <br />
@ -100,7 +96,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</div> </div>
)} )}
{!loadingRisk && !aiRisk && ( {!aiRisk && (
<p className="text-sm text-gray-500 mt-1">No AI risk data available</p> <p className="text-sm text-gray-500 mt-1">No AI risk data available</p>
)} )}
</div> </div>
@ -181,10 +177,10 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
<tr key={i}> <tr key={i}>
<td className="px-3 py-2 border-b">{row.percentile}</td> <td className="px-3 py-2 border-b">{row.percentile}</td>
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
${row.regionalSalary.toLocaleString()} {Number.isFinite(row.regionalSalary) ? `$${fmt(row.regionalSalary)}` : '—'}
</td> </td>
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
${row.nationalSalary.toLocaleString()} {Number.isFinite(row.nationalSalary) ? `$${fmt(row.nationalSalary)}` : '—'}
</td> </td>
</tr> </tr>
))} ))}
@ -219,12 +215,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</td> </td>
{careerDetails.economicProjections.state && ( {careerDetails.economicProjections.state && (
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
{careerDetails.economicProjections.state.base.toLocaleString()} {fmt(careerDetails.economicProjections.state.base)}
</td> </td>
)} )}
{careerDetails.economicProjections.national && ( {careerDetails.economicProjections.national && (
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
{careerDetails.economicProjections.national.base.toLocaleString()} {fmt(careerDetails.economicProjections.national.base)}
</td> </td>
)} )}
</tr> </tr>
@ -234,12 +230,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</td> </td>
{careerDetails.economicProjections.state && ( {careerDetails.economicProjections.state && (
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
{careerDetails.economicProjections.state.projection.toLocaleString()} {fmt(careerDetails.economicProjections.state.projection)}
</td> </td>
)} )}
{careerDetails.economicProjections.national && ( {careerDetails.economicProjections.national && (
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
{careerDetails.economicProjections.national.projection.toLocaleString()} {fmt(careerDetails.economicProjections.national.projection)}
</td> </td>
)} )}
</tr> </tr>
@ -247,12 +243,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
<td className="px-3 py-2 border-b font-semibold">Growth%</td> <td className="px-3 py-2 border-b font-semibold">Growth%</td>
{careerDetails.economicProjections.state && ( {careerDetails.economicProjections.state && (
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
{careerDetails.economicProjections.state.percentChange}% {fmt(careerDetails.economicProjections.state.percentChange)}%
</td> </td>
)} )}
{careerDetails.economicProjections.national && ( {careerDetails.economicProjections.national && (
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
{careerDetails.economicProjections.national.percentChange}% {fmt(careerDetails.economicProjections.national.percentChange)}%
</td> </td>
)} )}
</tr> </tr>
@ -262,12 +258,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</td> </td>
{careerDetails.economicProjections.state && ( {careerDetails.economicProjections.state && (
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
{careerDetails.economicProjections.state.annualOpenings.toLocaleString()} {fmt(careerDetails.economicProjections.state.annualOpenings)}
</td> </td>
)} )}
{careerDetails.economicProjections.national && ( {careerDetails.economicProjections.national && (
<td className="px-3 py-2 border-b"> <td className="px-3 py-2 border-b">
{careerDetails.economicProjections.national.annualOpenings.toLocaleString()} {fmt(careerDetails.economicProjections.national.annualOpenings)}
</td> </td>
)} )}
</tr> </tr>

View File

@ -1,26 +1,44 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import apiFetch from '../auth/apiFetch.js';
export default function CareerProfileList() { export default function CareerProfileList() {
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
const nav = useNavigate(); const nav = useNavigate();
const token = localStorage.getItem('token');
useEffect(() => { useEffect(() => {
fetch('/api/premium/career-profile/all', { (async () => {
headers: { Authorization: `Bearer ${token}` } try {
}) const r = await apiFetch('/api/premium/career-profile/all');
.then(r => r.json()) if (!r.ok) {
.then(d => setRows(d.careerProfiles || [])); // apiFetch already fires session-expired on 401/403, just bail
}, [token]); return;
}
const d = await r.json();
setRows(d.careerProfiles || []);
} catch (e) {
console.error('Failed to load career profiles:', e);
}
})();
}, []);
async function remove(id) { async function remove(id) {
if (!window.confirm('Delete this career profile?')) return; if (!window.confirm('Delete this career profile?')) return;
await fetch(`/api/premium/career-profile/${id}`, { try {
method : 'DELETE', const r = await apiFetch(`/api/premium/career-profile/${id}`, {
headers: { Authorization: `Bearer ${token}` } method: 'DELETE',
}); });
setRows(rows.filter(r => r.id !== id)); if (!r.ok) {
// 401/403 will already be handled by apiFetch
const msg = await r.text().catch(() => 'Failed to delete');
alert(msg || 'Failed to delete');
return;
}
setRows(prev => prev.filter(row => row.id !== id));
} catch (e) {
console.error('Delete failed:', e);
alert('Failed to delete');
}
} }
return ( return (

View File

@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2'; import { Line, Bar } from 'react-chartjs-2';
import { format } from 'date-fns'; // ⬅ install if not already import { format } from 'date-fns'; // ⬅ install if not already
import zoomPlugin from 'chartjs-plugin-zoom'; import zoomPlugin from 'chartjs-plugin-zoom';
import axios from 'axios'; import api from '../auth/apiClient.js';
import { import {
Chart as ChartJS, Chart as ChartJS,
LineElement, LineElement,
@ -23,7 +23,7 @@ import MilestoneEditModal from './MilestoneEditModal.js';
import buildChartMarkers from '../utils/buildChartMarkers.js'; import buildChartMarkers from '../utils/buildChartMarkers.js';
import getMissingFields, { MISSING_LABELS } from '../utils/getMissingFields.js'; import getMissingFields, { MISSING_LABELS } from '../utils/getMissingFields.js';
import 'chartjs-adapter-date-fns'; import 'chartjs-adapter-date-fns';
import authFetch from '../utils/authFetch.js'; import apiFetch from '../auth/apiFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js'; import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import parseFloatOrZero from '../utils/ParseFloatorZero.js'; import parseFloatOrZero from '../utils/ParseFloatorZero.js';
import { getFullStateName } from '../utils/stateUtils.js'; import { getFullStateName } from '../utils/stateUtils.js';
@ -41,6 +41,7 @@ import differenceInMonths from 'date-fns/differenceInMonths';
import "../styles/legacy/MilestoneTimeline.legacy.css"; import "../styles/legacy/MilestoneTimeline.legacy.css";
const authFetch = apiFetch;
// -------------- // --------------
// Register ChartJS Plugins // Register ChartJS Plugins
// -------------- // --------------
@ -824,14 +825,14 @@ async function fetchAiRisk(socCode, careerName, description, tasks) {
try { try {
// 1) Check server2 for existing entry // 1) Check server2 for existing entry
const localRiskRes = await axios.get(`/api/ai-risk/${socCode}`); const localRiskRes = await api.get(`/api/ai-risk/${socCode}`);
aiRisk = localRiskRes.data; // { socCode, riskLevel, ... } aiRisk = localRiskRes.data; // { socCode, riskLevel, ... }
} catch (err) { } catch (err) {
// 2) If 404 => call server3 // 2) If 404 => call server3
if (err.response && err.response.status === 404) { if (err.response && err.response.status === 404) {
try { try {
// Call GPT via server3 // Call GPT via server3
const aiRes = await axios.post('/api/public/ai-risk-analysis', { const aiRes = await api.post('/api/public/ai-risk-analysis', {
socCode, socCode,
careerName, careerName,
jobDescription: description, jobDescription: description,
@ -865,7 +866,7 @@ try {
} }
// 3) Store in server2 // 3) Store in server2
await axios.post('/api/ai-risk', storePayload); await api.post('/api/ai-risk', storePayload);
// Construct final object for usage here // Construct final object for usage here
aiRisk = { aiRisk = {
@ -902,7 +903,10 @@ useEffect(() => {
(async () => { (async () => {
try { try {
const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea }); const qs = new URLSearchParams({ socCode: strippedSocCode, area: userArea });
const res = await fetch(`/api/salary?${qs}`, { signal: ctrl.signal }); const res = await fetch(`/api/salary?${qs}`, {
signal: ctrl.signal,
credentials: 'include'
});
if (res.ok) { if (res.ok) {
setSalaryData(await res.json()); setSalaryData(await res.json());
@ -1117,8 +1121,6 @@ if (allMilestones.length) {
randomRangeMax randomRangeMax
}; };
console.log('Merged profile to simulate =>', mergedProfile);
const { projectionData: pData, loanPaidOffMonth } = const { projectionData: pData, loanPaidOffMonth } =
simulateFinancialProjection(mergedProfile); simulateFinancialProjection(mergedProfile);

View File

@ -7,6 +7,20 @@ import { Input } from './ui/input.js';
import { MessageCircle } from 'lucide-react'; import { MessageCircle } from 'lucide-react';
import RetirementChatBar from './RetirementChatBar.js'; import RetirementChatBar from './RetirementChatBar.js';
async function ensureSupportThread() {
const r = await fetch('/api/support/chat/threads', { credentials:'include' });
const { threads } = await r.json();
if (threads?.length) return threads[0].id;
const r2 = await fetch('/api/support/chat/threads', {
method: 'POST',
credentials:'include',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ title: 'Support chat' })
});
const { id } = await r2.json();
return id;
}
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* ChatDrawer /* ChatDrawer
support-bot lives in this file (streamed from /api/chat/free) support-bot lives in this file (streamed from /api/chat/free)
@ -27,6 +41,7 @@ export default function ChatDrawer({
/* ─────────────────────────── internal / fallback state ───────── */ /* ─────────────────────────── internal / fallback state ───────── */
const [openLocal, setOpenLocal] = useState(false); const [openLocal, setOpenLocal] = useState(false);
const [paneLocal, setPaneLocal] = useState('support'); const [paneLocal, setPaneLocal] = useState('support');
const [supportThreadId, setSupportThreadId] = useState(null);
/* prefer the controlled props when supplied */ /* prefer the controlled props when supplied */
const open = controlledOpen ?? openLocal; const open = controlledOpen ?? openLocal;
@ -45,6 +60,17 @@ export default function ChatDrawer({
(listRef.current.scrollTop = listRef.current.scrollHeight); (listRef.current.scrollTop = listRef.current.scrollHeight);
}, [messages]); }, [messages]);
useEffect(() => {
(async () => {
const id = await ensureSupportThread();
setSupportThreadId(id);
// preload messages if you want:
const r = await fetch(`/api/support/chat/threads/${id}`, { credentials:'include' });
const { messages: msgs } = await r.json();
setMessages(msgs || []);
})();
}, []);
/* helper: merge chunks while streaming */ /* helper: merge chunks while streaming */
const pushAssistant = (chunk) => const pushAssistant = (chunk) =>
setMessages((prev) => { setMessages((prev) => {
@ -70,30 +96,17 @@ export default function ChatDrawer({
/* ───────────────────────── send support-bot prompt ───────────── */ /* ───────────────────────── send support-bot prompt ───────────── */
async function sendPrompt() { async function sendPrompt() {
const text = prompt.trim(); const text = prompt.trim();
if (!text) return; if (!text || !supportThreadId) return;
setMessages((m) => [...m, { role: 'user', content: text }]); setMessages(m => [...m, { role:'user', content:text }]);
setPrompt(''); setPrompt('');
const body = JSON.stringify({
prompt: text,
pageContext,
chatHistory: messages,
snapshot,
});
try { try {
const token = localStorage.getItem('token') || ''; const resp = await fetch(`/api/support/chat/threads/${supportThreadId}/stream`, {
const headers = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const resp = await fetch('/api/chat/free', {
method: 'POST', method: 'POST',
headers, credentials: 'include',
body, headers: { 'Content-Type':'application/json', Accept:'text/event-stream' },
body: JSON.stringify({ prompt: text, pageContext, snapshot })
}); });
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`); if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
@ -102,13 +115,10 @@ export default function ChatDrawer({
let buf = ''; let buf = '';
while (true) { while (true) {
/* eslint-disable no-await-in-loop */
const { value, done } = await reader.read(); const { value, done } = await reader.read();
/* eslint-enable no-await-in-loop */
if (done) break; if (done) break;
if (!value) continue; if (!value) continue;
buf += decoder.decode(value, { stream:true });
buf += decoder.decode(value, { stream: true });
let nl; let nl;
while ((nl = buf.indexOf('\n')) !== -1) { while ((nl = buf.indexOf('\n')) !== -1) {
@ -118,13 +128,12 @@ export default function ChatDrawer({
} }
} }
if (buf.trim()) pushAssistant(buf); if (buf.trim()) pushAssistant(buf);
} catch (err) { } catch (e) {
console.error('[ChatDrawer] stream error', err); console.error('[Support stream]', e);
pushAssistant( pushAssistant('Sorry — something went wrong. Please try again later.');
'Sorry — something went wrong. Please try again later.'
);
}
} }
}
const handleKeyDown = (e) => { const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {

View File

@ -1,128 +0,0 @@
import React, { useState } from "react";
import axios from "axios";
import "../styles/legacy/Chatbot.legacy.css";
const Chatbot = ({ context }) => {
const [messages, setMessages] = useState([
{
role: "assistant",
content:
"Hi! Im here to help you with suggestions, analyzing career options, and any questions you have about your career. How can I assist you today?",
},
]);
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
// NEW: track whether the chatbot is minimized or expanded
const [isMinimized, setIsMinimized] = useState(false);
const toggleMinimize = () => {
setIsMinimized((prev) => !prev);
};
const sendMessage = async (content) => {
const userMessage = { role: "user", content };
// Build your context summary
const contextSummary = `
You are an advanced AI career advisor for AptivaAI.
Your role is to provide analysis based on user data:
- ...
(Continue with your existing context data as before)
`;
// Combine with existing messages
const messagesToSend = [
{ role: "system", content: contextSummary },
...messages,
userMessage,
];
try {
setLoading(true);
const response = await axios.post(
"https://api.openai.com/v1/chat/completions",
{
model: "gpt-3.5-turbo",
messages: messagesToSend,
temperature: 0.7,
},
{
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.REACT_APP_OPENAI_API_KEY}`,
},
}
);
const botMessage = response.data.choices[0].message;
// The returned message has {role: "assistant", content: "..."}
setMessages([...messages, userMessage, botMessage]);
} catch (error) {
console.error("Chatbot Error:", error);
setMessages([
...messages,
userMessage,
{
role: "assistant",
content: "Error: Unable to fetch response. Please try again.",
},
]);
} finally {
setLoading(false);
setInput("");
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (input.trim()) {
sendMessage(input.trim());
}
};
return (
<div className={`chatbot-container ${isMinimized ? "minimized" : ""}`}>
{/* Header Bar for Minimize/Maximize */}
<div className="chatbot-header">
<span className="chatbot-title">Career Chatbot</span>
<button className="minimize-btn" onClick={toggleMinimize}>
{isMinimized ? "▼" : "▲"}
</button>
</div>
{/* If not minimized, show the chat messages and input */}
{!isMinimized && (
<>
<div className="chat-messages">
{messages.map((msg, index) => {
// default to 'bot' if role not user or assistant
const roleClass =
msg.role === "user" ? "user" : msg.role === "assistant" ? "bot" : "bot";
return (
<div key={index} className={`message ${roleClass}`}>
{msg.content}
</div>
);
})}
{loading && <div className="message bot typing">Typing...</div>}
</div>
<form onSubmit={handleSubmit} className="chat-input-form">
<input
type="text"
placeholder="Ask a question..."
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={loading}
/>
<button type="submit" disabled={loading}>
Send
</button>
</form>
</>
)}
</div>
);
};
export default Chatbot;

View File

@ -1,9 +1,9 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import authFetch from '../utils/authFetch.js'; import apiFetch from '../auth/apiFetch.js';
import moment from 'moment/moment.js'; import moment from 'moment/moment.js';
const authFetch = apiFetch; // keep local name, new implementation
/** ----------------------------------------------------------- /** -----------------------------------------------------------
* Ensure numerics are sent as numbers and booleans as 0 / 1 * Ensure numerics are sent as numbers and booleans as 0 / 1
* mirrors the logic you use in OnboardingContainer * mirrors the logic you use in OnboardingContainer
@ -45,7 +45,6 @@ const toMySqlDate = iso => {
export default function CollegeProfileForm() { export default function CollegeProfileForm() {
const { careerId, id } = useParams(); // id optional const { careerId, id } = useParams(); // id optional
const nav = useNavigate(); const nav = useNavigate();
const token = localStorage.getItem('token');
const [cipRows, setCipRows] = useState([]); const [cipRows, setCipRows] = useState([]);
const [schoolSug, setSchoolSug] = useState([]); const [schoolSug, setSchoolSug] = useState([]);
const [progSug, setProgSug] = useState([]); const [progSug, setProgSug] = useState([]);
@ -126,13 +125,12 @@ const onProgramInput = (e) => {
useEffect(() => { useEffect(() => {
if (id && id !== 'new') { if (id && id !== 'new') {
fetch(`/api/premium/college-profile?careerProfileId=${careerId}`, { (async () => {
headers: { Authorization: `Bearer ${token}` } const r = await authFetch(`/api/premium/college-profile?careerProfileId=${careerId}`);
}) if (r.ok) setForm(await r.json());
.then(r => r.json()) })();
.then(setForm);
} }
}, [careerId, id, token]); }, [careerId, id]);
async function handleSave(){ async function handleSave(){
try{ try{
@ -152,7 +150,7 @@ const onProgramInput = (e) => {
/* LOAD iPEDS ----------------------------- */ /* LOAD iPEDS ----------------------------- */
useEffect(() => { useEffect(() => {
fetch('/ic2023_ay.csv') fetch('/ic2023_ay.csv', { credentials: 'omit' })
.then(r => r.text()) .then(r => r.text())
.then(text => { .then(text => {
const rows = text.split('\n').map(l => l.split(',')); const rows = text.split('\n').map(l => l.split(','));
@ -165,7 +163,7 @@ useEffect(() => {
.catch(err => console.error('iPEDS load failed', err)); .catch(err => console.error('iPEDS load failed', err));
}, []); }, []);
useEffect(() => { fetch('/cip_institution_mapping_new.json') useEffect(() => { fetch('/cip_institution_mapping_new.json', { credentials: 'omit' })
.then(r=>r.text()).then(t => setCipRows( .then(r=>r.text()).then(t => setCipRows(
t.split('\n').map(l=>{try{return JSON.parse(l)}catch{ return null }}) t.split('\n').map(l=>{try{return JSON.parse(l)}catch{ return null }})
.filter(Boolean) .filter(Boolean)
@ -235,9 +233,11 @@ useEffect(() => {
]); ]);
const handleManualTuitionChange = e => setManualTuition(e.target.value); const handleManualTuitionChange = e => setManualTuition(e.target.value);
const chosenTuition = manualTuition.trim() === '' const chosenTuition = (() => {
? autoTuition if (manualTuition.trim() === '') return autoTuition;
: parseFloat(manualTuition); const n = parseFloat(manualTuition);
return Number.isFinite(n) ? n : autoTuition;
})();
/* /*
Autocalculate PROGRAM LENGTH when the user hasnt typed in Autocalculate PROGRAM LENGTH when the user hasnt typed in

View File

@ -2,12 +2,11 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import CareerSelectDropdown from "./CareerSelectDropdown.js"; import CareerSelectDropdown from "./CareerSelectDropdown.js";
import authFetch from "../utils/authFetch.js"; import apiFetch from '../auth/apiFetch.js';
export default function CollegeProfileList() { export default function CollegeProfileList() {
const { careerId } = useParams(); // may be undefined const { careerId } = useParams(); // may be undefined
const navigate = useNavigate(); const navigate = useNavigate();
const token = localStorage.getItem("token");
/* ───────── existing lists ───────── */ /* ───────── existing lists ───────── */
const [rows, setRows] = useState([]); const [rows, setRows] = useState([]);
@ -17,20 +16,30 @@ export default function CollegeProfileList() {
const [showPicker, setShowPicker] = useState(false); const [showPicker, setShowPicker] = useState(false);
const [loadingCareers, setLoadingCareers] = useState(true); const [loadingCareers, setLoadingCareers] = useState(true);
const authFetch = apiFetch;
/* ───────── load college plans ───────── */
/* ───────── load college plans ───────── */ /* ───────── load college plans ───────── */
useEffect(() => { useEffect(() => {
fetch("/api/premium/college-profile/all", { (async () => {
headers: { Authorization: `Bearer ${token}` } try {
}) const r = await authFetch("/api/premium/college-profile/all");
.then((r) => r.json()) if (!r.ok) throw new Error(`load college-profile/all → ${r.status}`);
.then((d) => setRows(d.collegeProfiles || [])); const d = await r.json();
}, [token]); setRows(d.collegeProfiles || []);
} catch (err) {
console.error("College profiles load failed:", err);
setRows([]);
}
})();
}, []);
/* ───────── load career profiles for the picker ───────── */ /* ───────── load career profiles for the picker ───────── */
useEffect(() => { useEffect(() => {
(async () => { (async () => {
try { try {
const res = await authFetch("/api/premium/career-profile/all"); const res = await authFetch("/api/premium/career-profile/all");
if (!res.ok) throw new Error(`load career-profile/all → ${res.status}`);
const data = await res.json(); const data = await res.json();
setCareerRows(data.careerProfiles || []); setCareerRows(data.careerProfiles || []);
} catch (err) { } catch (err) {
@ -45,10 +54,10 @@ export default function CollegeProfileList() {
async function handleDelete(id) { async function handleDelete(id) {
if (!window.confirm("Delete this college plan?")) return; if (!window.confirm("Delete this college plan?")) return;
try { try {
await fetch(`/api/premium/college-profile/${id}`, { const res = await authFetch(`/api/premium/college-profile/${id}`, {
method: "DELETE", method: "DELETE",
headers: { Authorization: `Bearer ${token}` }
}); });
if (!res.ok) throw new Error(`delete failed → ${res.status}`);
setRows((r) => r.filter((row) => row.id !== id)); setRows((r) => r.filter((row) => row.id !== id));
} catch (err) { } catch (err) {
console.error("Delete failed:", err); console.error("Delete failed:", err);
@ -56,6 +65,7 @@ export default function CollegeProfileList() {
} }
} }
return ( return (
<div className="max-w-5xl mx-auto space-y-6"> <div className="max-w-5xl mx-auto space-y-6">
{/* ───────── header row ───────── */} {/* ───────── header row ───────── */}

View File

@ -4,6 +4,35 @@ import CareerSearch from './CareerSearch.js';
import { ONET_DEFINITIONS } from './definitions.js'; import { ONET_DEFINITIONS } from './definitions.js';
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js'; import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.js';
import ChatCtx from '../contexts/ChatCtx.js'; import ChatCtx from '../contexts/ChatCtx.js';
import api from '../auth/apiClient.js';
// Normalize DB/GPT KSA payloads into IM/LV rows for combineIMandLV
function normalizeKsaPayloadForCombine(payload, socCode) {
if (!payload) return [];
const out = [];
const coerce = (arr = [], ksa_type) => {
arr.forEach((it) => {
const name = it.elementName || it.name || it.title || '';
// If already IM/LV-shaped, just pass through
if (it.scaleID && it.dataValue != null) {
out.push({ ...it, onetSocCode: socCode, ksa_type, elementName: name });
return;
}
// Otherwise split combined values into IM/LV rows if present
const imp = it.importanceValue ?? it.importance ?? it.importanceScore;
const lvl = it.levelValue ?? it.level ?? it.levelScore;
if (imp != null) out.push({ onetSocCode: socCode, elementName: name, ksa_type, scaleID: 'IM', dataValue: imp });
if (lvl != null) out.push({ onetSocCode: socCode, elementName: name, ksa_type, scaleID: 'LV', dataValue: lvl });
});
};
coerce(payload.knowledge, 'Knowledge');
coerce(payload.skills, 'Skill');
coerce(payload.abilities, 'Ability');
return out;
}
// Helper to combine IM and LV for each KSA // Helper to combine IM and LV for each KSA
function combineIMandLV(rows) { function combineIMandLV(rows) {
@ -90,6 +119,7 @@ function EducationalProgramsPage() {
const [showSearch, setShowSearch] = useState(true); const [showSearch, setShowSearch] = useState(true);
const { setChatSnapshot } = useContext(ChatCtx); const { setChatSnapshot } = useContext(ChatCtx);
@ -143,10 +173,6 @@ function normalizeCipList(arr) {
'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?' 'Youre about to move to the financial planning portion of the app, which is reserved for premium subscribers. Do you want to continue?'
); );
if (proceed) { if (proceed) {
const storedOnboarding = JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}');
storedOnboarding.collegeData = storedOnboarding.collegeData || {};
storedOnboarding.collegeData.selectedSchool = school; // or any property name
localStorage.setItem('premiumOnboardingState', JSON.stringify(storedOnboarding));
navigate('/career-roadmap', { state: { selectedSchool: school } }); navigate('/career-roadmap', { state: { selectedSchool: school } });
} }
}; };
@ -235,7 +261,7 @@ useEffect(() => {
if (combined.length === 0) { if (combined.length === 0) {
// We found ZERO local KSA records for this socCode => fallback // We found ZERO local KSA records for this socCode => fallback
fetchAiKsaFallback(socCode, careerTitle); fetchKsaFallback(socCode, careerTitle);
} else { } else {
// We found local KSA data => just use it // We found local KSA data => just use it
setKsaForCareer(combined); setKsaForCareer(combined);
@ -243,19 +269,11 @@ useEffect(() => {
}, [socCode, allKsaData, careerTitle]); }, [socCode, allKsaData, careerTitle]);
// Load user profile // Load user profile
// Load user profile (cookie-based auth via api client)
useEffect(() => { useEffect(() => {
async function loadUserProfile() { async function loadUserProfile() {
try { try {
const token = localStorage.getItem('token'); const { data } = await api.get('/api/user-profile');
if (!token) {
console.warn('No token found, cannot load user-profile.');
return;
}
const res = await fetch('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error('Failed to fetch user profile');
const data = await res.json();
setUserZip(data.zipcode || ''); setUserZip(data.zipcode || '');
setUserState(data.state || ''); setUserState(data.state || '');
} catch (err) { } catch (err) {
@ -582,50 +600,49 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
); );
} }
async function fetchAiKsaFallback(socCode, careerTitle) { // No local KSA records for this SOC => ask server3 to resolve (local/DB/GPT)
// Optionally show a “loading” indicator async function fetchKsaFallback(socCode, careerTitle) {
setLoadingKsa(true); setLoadingKsa(true);
setKsaError(null); setKsaError(null);
try { try {
const token = localStorage.getItem('token'); // Ask server3. It will:
if (!token) { // 1) Serve local ksa_data.json if present for this SOC
throw new Error('No auth token found; cannot fetch AI-based KSAs.'); // 2) Otherwise return DB ai_generated_ksa (IM/LV rows)
} // 3) Otherwise call GPT, normalize to IM/LV, store in DB, and return it
const resp = await api.get(`/api/premium/ksa/${socCode}`, {
// Call the new endpoint in server3.js params: { careerTitle: careerTitle || '' }
const resp = await fetch(
`/api/premium/ksa/${socCode}?careerTitle=${encodeURIComponent(careerTitle)}`,
{
headers: {
Authorization: `Bearer ${token}`
}
}
);
if (!resp.ok) {
throw new Error(`AI KSA endpoint returned status ${resp.status}`);
}
const json = await resp.json();
// Expect shape: { source: 'chatgpt' | 'db' | 'local', data: { knowledge, skills, abilities } }
// The arrays from server may already be in the “IM/LV” format
// so we can combine them into one array for display:
const finalKsa = [...json.data.knowledge, ...json.data.skills, ...json.data.abilities];
finalKsa.forEach(item => {
item.onetSocCode = socCode;
}); });
const combined = combineIMandLV(finalKsa);
// server3 returns either:
// { source: 'local', data: [IM/LV rows...] }
// or
// { source: 'db'|'chatgpt', data: { knowledge:[], skills:[], abilities:[] } }
const payload = resp?.data?.data ?? resp?.data;
let rows;
if (Array.isArray(payload)) {
// Already IM/LV rows
rows = payload;
} else {
// Object with knowledge/skills/abilities
rows = normalizeKsaPayloadForCombine(payload, socCode);
}
const combined = combineIMandLV(rows)
.filter(i => i.importanceValue != null && i.importanceValue >= 3)
.sort((a, b) => (b.importanceValue || 0) - (a.importanceValue || 0));
setKsaForCareer(combined); setKsaForCareer(combined);
} catch (err) { } catch (err) {
console.error('Error fetching AI-based KSAs:', err); console.error('Error fetching KSAs:', err);
setKsaError('Could not load AI-based KSAs. Please try again later.'); setKsaError('Could not load KSAs. Please try again later.');
setKsaForCareer([]); setKsaForCareer([]);
} finally { } finally {
setLoadingKsa(false); setLoadingKsa(false);
} }
} }
return ( return (
<div className="p-4"> <div className="p-4">

View File

@ -6,61 +6,76 @@ import { Button } from './ui/button.js';
export default function Paywall() { export default function Paywall() {
const nav = useNavigate(); const nav = useNavigate();
const [sub, setSub] = useState(null); // null = loading const [sub, setSub] = useState(null); // null = loading
const token = localStorage.getItem('token') || '';
/* ───────────────── fetch current subscription ─────────────── */ // Fetch current subscription using cookie/session auth
useEffect(() => { useEffect(() => {
fetch('/api/premium/subscription/status', { let cancelled = false;
headers: { Authorization: `Bearer ${token}` } (async () => {
}) try {
.then(r => r.ok ? r.json() : Promise.reject(r.status)) const r = await fetch('/api/premium/subscription/status', {
.then(setSub) credentials: 'include',
.catch(() => setSub({ is_premium:0, is_pro_premium:0 })); });
}, [token]); if (!r.ok) throw new Error(String(r.status));
const json = await r.json();
if (!cancelled) setSub(json);
} catch {
if (!cancelled) setSub({ is_premium: 0, is_pro_premium: 0 });
}
})();
return () => { cancelled = true; };
}, []);
/* ───────────────── helpers ────────────────────────────────── */
const checkout = useCallback(async (tier, cycle) => { const checkout = useCallback(async (tier, cycle) => {
const base = window.location.origin; // https://dev1.aptivaai.com try {
const base = window.location.origin;
const res = await fetch('/api/premium/stripe/create-checkout-session', { const res = await fetch('/api/premium/stripe/create-checkout-session', {
method : 'POST', method: 'POST',
headers: { credentials: 'include',
'Content-Type': 'application/json', headers: { 'Content-Type': 'application/json' },
Authorization : `Bearer ${token}`
},
body: JSON.stringify({ body: JSON.stringify({
tier, tier,
cycle, cycle,
success_url: `${base}/billing?ck=success`, success_url: `${base}/billing?ck=success`,
cancel_url : `${base}/billing?ck=cancel` cancel_url : `${base}/billing?ck=cancel`,
}) }),
}); });
if (!res.ok) return console.error('Checkout failed', await res.text()); if (!res.ok) {
console.error('Checkout failed', await res.text());
return;
}
const { url } = await res.json(); const { url } = await res.json();
window.location.href = url; // redirect to Stripe window.location.href = url;
}, [token]); } catch (err) {
console.error('Checkout error', err);
}
}, []);
const openPortal = useCallback(async () => { const openPortal = useCallback(async () => {
try {
const base = window.location.origin; const base = window.location.origin;
const res = await fetch(`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`, { const res = await fetch(
headers: { Authorization: `Bearer ${token}` } `/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`,
}); { credentials: 'include' }
if (!res.ok) return console.error('Portal error', await res.text()); );
window.location.href = (await res.json()).url; if (!res.ok) {
}, [token]); console.error('Portal error', await res.text());
return;
}
const { url } = await res.json();
window.location.href = url;
} catch (err) {
console.error('Portal open error', err);
}
}, []);
/* ───────────────── render ─────────────────────────────────── */ if (!sub) return <p className="p-6 text-center text-sm">Loading</p>;
if (!sub) return <p className="p-6 text-center text-sm">Loading</p>;
if (sub.is_premium || sub.is_pro_premium) { if (sub.is_premium || sub.is_pro_premium) {
const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium'; const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium';
return ( return (
<div className="max-w-lg mx-auto p-6 text-center space-y-4"> <div className="max-w-lg mx-auto p-6 text-center space-y-4">
<h2 className="text-xl font-semibold">Your plan: {plan}</h2> <h2 className="text-xl font-semibold">Your plan: {plan}</h2>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">Manage payment method, invoices or cancel anytime.</p>
Manage payment method, invoices or cancel anytime.
</p>
<Button onClick={openPortal} className="w-full"> <Button onClick={openPortal} className="w-full">
Manage subscription Manage subscription
@ -73,43 +88,41 @@ export default function Paywall() {
); );
} }
/* ─── no active sub => show the pricing choices ──────────────── */ // No active sub => pricing
return ( return (
<div className="max-w-lg mx-auto p-6 space-y-8"> <div className="max-w-lg mx-auto p-6 space-y-8">
<header className="text-center"> <header className="text-center">
<h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2> <h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2>
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">Choose the plan that fits your needs cancel anytime.</p>
Choose the plan that fits your needs cancel anytime.
</p>
</header> </header>
{/* Premium tier */} {/* Premium */}
<section className="border rounded-lg p-4 space-y-4"> <section className="border rounded-lg p-4 space-y-4">
<h3 className="text-lg font-medium">Premium</h3> <h3 className="text-lg font-medium">Premium</h3>
<ul className="text-sm list-disc list-inside space-y-1"> <ul className="text-sm list-disc list-inside space-y-1">
<li>Career milestone planning</li> <li>Career milestone planning</li>
<li>Financial projections &amp; benchmarks</li> <li>Financial projections &amp; benchmarks</li>
<li>2×resume optimizations / week</li> <li>2 × resume optimizations / week</li>
</ul> </ul>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Button onClick={() => checkout('premium', 'monthly')}>$4.99&nbsp;/mo</Button> <Button onClick={() => checkout('premium', 'monthly')}>$4.99&nbsp;/ mo</Button>
<Button onClick={() => checkout('premium', 'annual' )}>$49&nbsp;/yr</Button> <Button onClick={() => checkout('premium', 'annual')}>$49&nbsp;/ yr</Button>
</div> </div>
</section> </section>
{/* Pro tier */} {/* Pro */}
<section className="border rounded-lg p-4 space-y-4"> <section className="border rounded-lg p-4 space-y-4">
<h3 className="text-lg font-medium">Pro Premium</h3> <h3 className="text-lg font-medium">Pro Premium</h3>
<ul className="text-sm list-disc list-inside space-y-1"> <ul className="text-sm list-disc list-inside space-y-1">
<li>Everything in Premium</li> <li>Everything in Premium</li>
<li>Priority GPT4o usage &amp; higher rate limits</li> <li>Priority GPT-4o usage &amp; higher rate limits</li>
<li>5×resume optimizations / week</li> <li>5 × resume optimizations / week</li>
</ul> </ul>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Button onClick={() => checkout('pro', 'monthly')}>$7.99&nbsp;/mo</Button> <Button onClick={() => checkout('pro', 'monthly')}>$7.99&nbsp;/ mo</Button>
<Button onClick={() => checkout('pro', 'annual' )}>$79&nbsp;/yr</Button> <Button onClick={() => checkout('pro', 'annual')}>$79&nbsp;/ yr</Button>
</div> </div>
</section> </section>

View File

@ -38,9 +38,7 @@ function dehydrate(schObj) {
} }
const [selectedSchool, setSelectedSchool] = useState(() => const [selectedSchool, setSelectedSchool] = useState(() =>
dehydrate(navSelectedSchool) || dehydrate(navSelectedSchool) || (data.selected_school ? { INSTNM: data.selected_school } : null)
dehydrate(JSON.parse(localStorage.getItem('premiumOnboardingState') || '{}'
).collegeData?.selectedSchool)
); );
function toSchoolName(objOrStr) { function toSchoolName(objOrStr) {

View File

@ -1,258 +1,208 @@
import React, { useState, useEffect } from 'react'; // src/pages/premium/OnboardingContainer.js
import { useNavigate } from 'react-router-dom'; import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import PremiumWelcome from './PremiumWelcome.js'; import PremiumWelcome from './PremiumWelcome.js';
import CareerOnboarding from './CareerOnboarding.js'; import CareerOnboarding from './CareerOnboarding.js';
import FinancialOnboarding from './FinancialOnboarding.js'; import FinancialOnboarding from './FinancialOnboarding.js';
import CollegeOnboarding from './CollegeOnboarding.js'; import CollegeOnboarding from './CollegeOnboarding.js';
import ReviewPage from './ReviewPage.js'; import ReviewPage from './ReviewPage.js';
import { loadDraft, saveDraft, clearDraft } from '../../utils/onboardingDraftApi.js';
import authFetch from '../../utils/authFetch.js'; import authFetch from '../../utils/authFetch.js';
const OnboardingContainer = () => { const POINTER_KEY = 'premiumOnboardingPointer';
console.log('OnboardingContainer MOUNT');
export default function OnboardingContainer() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
// 1. Local state for multi-step onboarding
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
/**
* Suppose `careerData.career_profile_id` is how we store the existing profile's ID
* If it's blank/undefined, that means "create new." If it has a value, we do an update.
*/
const [careerData, setCareerData] = useState({}); const [careerData, setCareerData] = useState({});
const [financialData, setFinancialData] = useState({}); const [financialData, setFinancialData] = useState({});
const [collegeData, setCollegeData] = useState({}); const [collegeData, setCollegeData] = useState({});
const [lastSelectedCareerProfileId, setLastSelectedCareerProfileId] = useState(); const [loaded, setLoaded] = useState(false);
const skipFin = careerData.skipFinancialStep;
// pointer (safe to store)
const ptrRef = useRef({ id: null, step: 0, skipFin: false, selectedCareer: null });
// ---- 1) one-time load/migrate & hydrate -----------------------
useEffect(() => { useEffect(() => {
// 1) Load premiumOnboardingState (async () => {
const stored = localStorage.getItem('premiumOnboardingState'); // A) migrate any old local blob (once), then delete it
let localCareerData = {}; const oldRaw = localStorage.getItem('premiumOnboardingState'); // legacy
let localFinancialData = {}; if (oldRaw) {
let localCollegeData = {};
let localStep = 0;
if (stored) {
try { try {
const parsed = JSON.parse(stored); const legacy = JSON.parse(oldRaw);
if (parsed.step !== undefined) localStep = parsed.step; const draft = await saveDraft({
if (parsed.careerData) localCareerData = parsed.careerData; id: null,
if (parsed.financialData) localFinancialData = parsed.financialData; step: legacy.step ?? 0,
if (parsed.collegeData) localCollegeData = parsed.collegeData; careerData: legacy.careerData || {},
} catch (err) { financialData: legacy.financialData || {},
console.warn('Failed to parse premiumOnboardingState:', err); 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');
} }
} }
// 2) If there's a "lastSelectedCareerProfileId", override or set the career_profile_id // B) load pointer
const existingId = localStorage.getItem('lastSelectedCareerProfileId'); try {
if (existingId) { const pointer = JSON.parse(localStorage.getItem(POINTER_KEY) || 'null') || {};
// Only override if there's no existing ID in localCareerData ptrRef.current = {
// or if you specifically want to *always* use the lastSelected ID. id: pointer.id || null,
localCareerData.career_profile_id = existingId; step: Number.isInteger(pointer.step) ? pointer.step : 0,
skipFin: !!pointer.skipFin,
selectedCareer: pointer.selectedCareer || JSON.parse(localStorage.getItem('selectedCareer') || 'null')
};
} catch { /* ignore */ }
// C) fetch draft from server (source of truth)
const draft = await loadDraft();
if (draft) {
ptrRef.current.id = draft.id; // ensure we have it
setStep(draft.step ?? ptrRef.current.step ?? 0);
const d = draft.data || {};
setCareerData(d.careerData || {});
setFinancialData(d.financialData || {});
setCollegeData(d.collegeData || {});
} else {
// no server draft yet: seed with minimal data from pointer/local selectedCareer
setStep(ptrRef.current.step || 0);
if (ptrRef.current.selectedCareer?.title) {
setCareerData(cd => ({
...cd,
career_name: ptrRef.current.selectedCareer.title,
soc_code: ptrRef.current.selectedCareer.soc_code || ''
}));
}
} }
// 3) Finally set states once // D) pick up any navigation state (e.g., selectedSchool)
setStep(localStep); const navSchool = location.state?.selectedSchool;
setCareerData(localCareerData); if (navSchool) {
setFinancialData(localFinancialData); setCollegeData(cd => ({ ...cd, selected_school: navSchool.INSTNM || navSchool }));
setCollegeData(localCollegeData); }
}, []);
// 3. Whenever any key pieces of state change, save to localStorage setLoaded(true);
})();
}, [location.state]);
// ---- 2) debounced autosave to server + pointer update ----------
useEffect(() => { useEffect(() => {
const stateToStore = { if (!loaded) return;
const t = setTimeout(async () => {
// persist server draft (all sensitive data)
const resp = await saveDraft({
id: ptrRef.current.id,
step, step,
careerData, careerData,
financialData, financialData,
collegeData 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')
}; };
localStorage.setItem('premiumOnboardingState', JSON.stringify(stateToStore)); ptrRef.current = pointer;
}, [step, careerData, financialData, collegeData]); localStorage.setItem(POINTER_KEY, JSON.stringify(pointer));
}, 400); // debounce
// Move user to next or previous step return () => clearTimeout(t);
const nextStep = () => setStep(prev => prev + 1); }, [loaded, step, careerData, financialData, collegeData]);
const prevStep = () => setStep(prev => prev - 1);
// Helper: parse float or return null // ---- nav helpers ------------------------------------------------
function parseFloatOrNull(value) { const nextStep = () => setStep((s) => s + 1);
if (value == null || value === '') return null; const prevStep = () => setStep((s) => Math.max(0, s - 1));
const parsed = parseFloat(value); // Steps: Welcome, Career, (Financial?), College, Review => 4 or 5 total
return isNaN(parsed) ? null : parsed; const finishImmediately = () => setStep(skipFin ? 3 : 4);
}
// ---- final submit (unchanged + cleanup) -------------------------
function finishImmediately() { async function handleFinalSubmit() {
// The review page is the last item in the steps array ⇒ index = onboardingSteps.length1
setStep(onboardingSteps.length - 1);
}
// 4. Final “all done” submission
const handleFinalSubmit = async () => {
try { try {
// -- 1) Upsert scenario (career-profile) -- // 1) scenario upsert
const scenarioPayload = { ...careerData, id: careerData.career_profile_id || undefined };
// If we already have an existing career_profile_id, pass it as "id"
// so the server does "ON DUPLICATE KEY UPDATE" instead of generating a new one.
// Otherwise, leave it undefined/null so the server creates a new record.
const scenarioPayload = {
...careerData,
id: careerData.career_profile_id || undefined
};
const scenarioRes = await authFetch('/api/premium/career-profile', { const scenarioRes = await authFetch('/api/premium/career-profile', {
method: 'POST', method: 'POST', headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scenarioPayload)
body: JSON.stringify(scenarioPayload),
}); });
if (!scenarioRes || !scenarioRes.ok) throw new Error('Failed to save (or update) career profile');
const { career_profile_id: finalId } = await scenarioRes.json();
if (!finalId) throw new Error('No career_profile_id returned by server');
if (!scenarioRes.ok) { // 2) financial profile
throw new Error('Failed to save (or update) career profile'); const finRes = await authFetch('/api/premium/financial-profile', {
} method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(financialData)
const scenarioJson = await scenarioRes.json();
let finalCareerProfileId = scenarioJson.career_profile_id;
if (!finalCareerProfileId) {
// If the server returns no ID for some reason, bail out
throw new Error('No career_profile_id returned by server');
}
// Update local state so we have the correct career_profile_id going forward
setCareerData(prev => ({
...prev,
career_profile_id: finalCareerProfileId
}));
// 2) Upsert financial-profile (optional)
const financialRes = await authFetch('/api/premium/financial-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(financialData),
}); });
if (!financialRes.ok) { if (!finRes || !finRes.ok) throw new Error('Failed to save financial profile');
throw new Error('Failed to save financial profile');
}
// 3) If user is in or planning college => upsert college-profile // 3) college profile (conditional)
if ( if (['currently_enrolled','prospective_student'].includes(careerData.college_enrollment_status)) {
careerData.college_enrollment_status === 'currently_enrolled' || const merged = {
careerData.college_enrollment_status === 'prospective_student'
) {
// Build an object that has all the correct property names
const mergedCollegeData = {
...collegeData, ...collegeData,
career_profile_id: finalCareerProfileId, career_profile_id: finalId,
college_enrollment_status: careerData.college_enrollment_status, college_enrollment_status: careerData.college_enrollment_status,
is_in_state: !!collegeData.is_in_state, is_in_state: !!collegeData.is_in_state,
is_in_district: !!collegeData.is_in_district, is_in_district: !!collegeData.is_in_district,
is_online: !!collegeData.is_online, // ensure it matches backend naming is_online: !!collegeData.is_online,
loan_deferral_until_graduation: !!collegeData.loan_deferral_until_graduation, loan_deferral_until_graduation: !!collegeData.loan_deferral_until_graduation
}; };
// numeric normalization (your existing parse rules apply)
const nums = ['existing_college_debt','extra_payment','tuition','tuition_paid','interest_rate',
'loan_term','credit_hours_per_year','credit_hours_required','hours_completed',
'program_length','expected_salary','annual_financial_aid'];
nums.forEach(k => { const n = Number(merged[k]); merged[k] = Number.isFinite(n) ? n : 0; });
// Convert numeric fields const colRes = await authFetch('/api/premium/college-profile', {
const numericFields = [ method: 'POST', headers: { 'Content-Type': 'application/json' },
'existing_college_debt', body: JSON.stringify(merged)
'extra_payment',
'tuition',
'tuition_paid',
'interest_rate',
'loan_term',
'credit_hours_per_year',
'credit_hours_required',
'hours_completed',
'program_length',
'expected_salary',
'annual_financial_aid'
];
numericFields.forEach(field => {
const val = parseFloatOrNull(mergedCollegeData[field]);
// If you want them to be 0 when blank, do:
mergedCollegeData[field] = val ?? 0;
}); });
if (!colRes || !colRes.ok) throw new Error('Failed to save college profile');
const collegeRes = await authFetch('/api/premium/college-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mergedCollegeData),
});
if (!collegeRes.ok) {
throw new Error('Failed to save college profile');
}
} else {
console.log(
'Skipping college-profile upsert; user not in or planning college.'
);
} }
const picked = { code: careerData.soc_code, title: careerData.career_name } // 4) UX handoff + cleanup
const picked = { code: careerData.soc_code, title: careerData.career_name };
sessionStorage.setItem('skipMissingModalFor', String(finalId));
localStorage.setItem('selectedCareer', JSON.stringify(picked));
localStorage.removeItem('lastSelectedCareerProfileId');
// 🚀 right before you navigate away from the review page // 🔐 cleanup: remove server draft + pointer
sessionStorage.setItem('skipMissingModalFor', String(finalCareerProfileId)); await clearDraft();
localStorage.setItem('selectedCareer', JSON.stringify(picked)); localStorage.removeItem(POINTER_KEY);
localStorage.removeItem('lastSelectedCareerProfileId');
navigate(`/career-roadmap/${finalCareerProfileId}`, {
state: { fromOnboarding: true,
selectedCareer : picked
}
});
navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } });
} catch (err) { } catch (err) {
console.error('Error in final submit =>', err); console.error('Error in final submit =>', err);
alert(err.message || 'Failed to finalize onboarding.'); alert(err.message || 'Failed to finalize onboarding.');
} }
}; }
const skipFin = !!careerData.skipFinancialStep;
// 5. Array of steps const steps = [
const onboardingSteps = [
<PremiumWelcome nextStep={nextStep} />, <PremiumWelcome nextStep={nextStep} />,
<CareerOnboarding nextStep={nextStep} finishNow={finishImmediately} data={careerData} setData={setCareerData} />,
<CareerOnboarding ...(!skipFin ? [ <FinancialOnboarding key="fin" nextStep={nextStep} prevStep={prevStep}
nextStep={nextStep} data={{ ...financialData, currently_working: careerData.currently_working }} setData={setFinancialData} /> ] : []),
finishNow={finishImmediately} <CollegeOnboarding prevStep={prevStep} nextStep={nextStep}
data={careerData} data={{ ...collegeData, college_enrollment_status: careerData.college_enrollment_status }} setData={setCollegeData} />,
setData={setCareerData} <ReviewPage careerData={careerData} financialData={financialData} collegeData={collegeData}
/>, onSubmit={handleFinalSubmit} onBack={prevStep} />
/* insert **only if** the user did NOT press “Skip for now” */
...(!skipFin
? [
<FinancialOnboarding
key="fin"
nextStep={nextStep}
prevStep={prevStep}
data={{
...financialData,
currently_working: careerData.currently_working,
}}
setData={setFinancialData}
/>,
]
: []),
<CollegeOnboarding
prevStep={prevStep}
nextStep={nextStep}
data={{
...collegeData,
college_enrollment_status: careerData.college_enrollment_status,
}}
setData={setCollegeData}
/>,
<ReviewPage
careerData={careerData}
financialData={financialData}
collegeData={collegeData}
onSubmit={handleFinalSubmit}
onBack={prevStep}
/>,
]; ];
return <div>{onboardingSteps[step]}</div>; const safeIndex = Math.min(step, steps.length - 1);
}; return <div>{loaded ? steps[safeIndex] : null}</div>;
}
export default OnboardingContainer;

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import axios from 'axios'; import api from '../auth/apiClient.js';
function ResumeRewrite() { function ResumeRewrite() {
const [resumeFile, setResumeFile] = useState(null); const [resumeFile, setResumeFile] = useState(null);
@ -9,20 +9,44 @@ function ResumeRewrite() {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [remainingOptimizations, setRemainingOptimizations] = useState(null); const [remainingOptimizations, setRemainingOptimizations] = useState(null);
const [resetDate, setResetDate] = useState(null); const [resetDate, setResetDate] = useState(null);
const [loading, setLoading] = useState(false); // ADDED loading state const [loading, setLoading] = useState(false);
const ALLOWED_TYPES = [
'application/pdf',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
];
const MAX_MB = 5;
const handleFileChange = (e) => { const handleFileChange = (e) => {
setResumeFile(e.target.files[0]); const f = e.target.files?.[0] || null;
setOptimizedResume('');
setError('');
if (!f) {
setResumeFile(null);
return;
}
// Basic client-side validation
if (!ALLOWED_TYPES.includes(f.type)) {
setError('Please upload a PDF or DOCX file.');
setResumeFile(null);
return;
}
if (f.size > MAX_MB * 1024 * 1024) {
setError(`File is too large. Maximum ${MAX_MB}MB.`);
setResumeFile(null);
return;
}
setResumeFile(f);
}; };
const fetchRemainingOptimizations = async () => { const fetchRemainingOptimizations = async () => {
try { try {
const token = localStorage.getItem('token'); const res = await api.get('/api/premium/resume/remaining', { withCredentials: true });
const res = await axios.get('/api/premium/resume/remaining', {
headers: { Authorization: `Bearer ${token}` },
});
setRemainingOptimizations(res.data.remainingOptimizations); setRemainingOptimizations(res.data.remainingOptimizations);
setResetDate(new Date(res.data.resetDate).toLocaleDateString()); setResetDate(res.data.resetDate ? new Date(res.data.resetDate).toLocaleDateString() : null);
} catch (err) { } catch (err) {
console.error('Error fetching optimizations:', err); console.error('Error fetching optimizations:', err);
setError('Could not fetch optimization limits.'); setError('Could not fetch optimization limits.');
@ -35,25 +59,24 @@ function ResumeRewrite() {
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setError('');
setOptimizedResume('');
if (!resumeFile || !jobTitle.trim() || !jobDescription.trim()) { if (!resumeFile || !jobTitle.trim() || !jobDescription.trim()) {
setError('Please fill in all fields.'); setError('Please fill in all fields.');
return; return;
} }
setLoading(true); // ACTIVATE loading setLoading(true);
try { try {
const token = localStorage.getItem('token');
const formData = new FormData(); const formData = new FormData();
formData.append('resumeFile', resumeFile); formData.append('resumeFile', resumeFile);
formData.append('jobTitle', jobTitle); formData.append('jobTitle', jobTitle.trim());
formData.append('jobDescription', jobDescription); formData.append('jobDescription', jobDescription.trim());
const res = await axios.post('/api/premium/resume/optimize', formData, { // Let axios/browser set multipart boundary automatically; just include credentials.
headers: { const res = await api.post('/api/premium/resume/optimize', formData, {
'Content-Type': 'multipart/form-data', withCredentials: true,
Authorization: `Bearer ${token}`,
},
}); });
setOptimizedResume(res.data.optimizedResume || ''); setOptimizedResume(res.data.optimizedResume || '');
@ -63,7 +86,7 @@ function ResumeRewrite() {
console.error('Resume optimization error:', err); console.error('Resume optimization error:', err);
setError(err.response?.data?.error || 'Failed to optimize resume.'); setError(err.response?.data?.error || 'Failed to optimize resume.');
} finally { } finally {
setLoading(false); // DEACTIVATE loading setLoading(false);
} }
}; };
@ -80,15 +103,26 @@ function ResumeRewrite() {
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
<div> <div>
<label className="block font-medium text-gray-700 mb-1">Upload Resume (PDF or DOCX):</label> <label className="block font-medium text-gray-700 mb-1">
<input type="file" accept=".pdf,.docx" onChange={handleFileChange} Upload Resume (PDF or DOCX):
</label>
<input
type="file"
accept=".pdf,.docx"
onChange={handleFileChange}
className="file:mr-4 file:py-2 file:px-4 file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer text-gray-600" className="file:mr-4 file:py-2 file:px-4 file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer text-gray-600"
/> />
</div> </div>
<div> <div>
<label className="block font-medium text-gray-700 mb-1">Job Title:</label> <label className="block font-medium text-gray-700 mb-1">Job Title:</label>
<input type="text" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} <input
type="text"
value={jobTitle}
onChange={(e) => {
setJobTitle(e.target.value);
if (error) setError('');
}}
className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200" className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200"
placeholder="e.g., Software Engineer" placeholder="e.g., Software Engineer"
/> />
@ -96,8 +130,14 @@ function ResumeRewrite() {
<div> <div>
<label className="block font-medium text-gray-700 mb-1">Job Description:</label> <label className="block font-medium text-gray-700 mb-1">Job Description:</label>
<textarea value={jobDescription} onChange={(e) => setJobDescription(e.target.value)} <textarea
rows={4} className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200" value={jobDescription}
onChange={(e) => {
setJobDescription(e.target.value);
if (error) setError('');
}}
rows={4}
className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200"
placeholder="Paste the job listing or requirements here..." placeholder="Paste the job listing or requirements here..."
/> />
</div> </div>
@ -125,7 +165,9 @@ function ResumeRewrite() {
{optimizedResume && ( {optimizedResume && (
<div className="mt-8"> <div className="mt-8">
<h3 className="text-xl font-bold mb-2">Optimized Resume</h3> <h3 className="text-xl font-bold mb-2">Optimized Resume</h3>
<pre className="whitespace-pre-wrap bg-gray-50 p-4 rounded border">{optimizedResume}</pre> <pre className="whitespace-pre-wrap bg-gray-50 p-4 rounded border">
{optimizedResume}
</pre>
</div> </div>
)} )}
</div> </div>

View File

@ -104,11 +104,35 @@ export default function RetirementChatBar({
const [forceCtx, setForceCtx] = useState(false); const [forceCtx, setForceCtx] = useState(false);
const [scenarios, setScenarios] = useState([]); const [scenarios, setScenarios] = useState([]);
const [currentScenario, setCurrentScenario] = useState(scenario); const [currentScenario, setCurrentScenario] = useState(scenario);
const [threadId, setThreadId] = useState(null);
const bottomRef = useRef(null); const bottomRef = useRef(null);
/* wipe chat on scenario change */ /* wipe chat on scenario change */
useEffect(() => setChatHistory([]), [currentScenario?.id]); useEffect(() => setChatHistory([]), [currentScenario?.id]);
useEffect(() => {
(async () => {
if (!currentScenario?.id) return;
const r = await authFetch('/api/premium/retire/chat/threads');
const { threads = [] } = await r.json();
let id = threads.find(Boolean)?.id;
if (!id) {
const r2 = await authFetch('/api/premium/retire/chat/threads', {
method:'POST',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ title: `Retirement • ${scenarioLabel}` })
});
({ id } = await r2.json());
}
setThreadId(id);
const r3 = await authFetch(`/api/premium/retire/chat/threads/${id}`);
const { messages: msgs = [] } = await r3.json();
setChatHistory(msgs);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentScenario?.id]);
/* fetch the users scenarios once */ /* fetch the users scenarios once */
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -156,15 +180,14 @@ async function sendPrompt() {
}); });
/* ③ POST to the retirement endpoint */ /* ③ POST to the retirement endpoint */
const res = await authFetch('/api/premium/retirement/aichat', { const res = await authFetch(`/api/premium/retire/chat/threads/${threadId}/messages`, {
method : 'POST', method:'POST',
headers: { 'Content-Type': 'application/json' }, headers:{ 'Content-Type':'application/json' },
body : JSON.stringify({ body: JSON.stringify({
prompt, content: prompt,
scenario_id : currentScenario?.id, // ← keep it minimal context: { scenario_id: currentScenario.id } // minimal — your backend uses it
chatHistory : messagesToSend // ← backend needs this to find userMsg
}) })
}); });
const data = await res.json(); const data = await res.json();
const assistantReply = data.reply || '(no response)'; const assistantReply = data.reply || '(no response)';

View File

@ -1,7 +1,7 @@
import React, { useRef, useState, useEffect, useContext } from 'react'; import React, { useRef, useState, useEffect, useContext } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { ProfileCtx } from '../App.js'; import { ProfileCtx } from '../App.js';
import { setToken } from '../auth/authMemory.js'; import * as safeLocal from '../utils/safeLocal.js';
function SignIn({ setIsAuthenticated, setUser }) { function SignIn({ setIsAuthenticated, setUser }) {
const navigate = useNavigate(); const navigate = useNavigate();
@ -13,7 +13,6 @@ function SignIn({ setIsAuthenticated, setUser }) {
const location = useLocation(); const location = useLocation();
useEffect(() => { useEffect(() => {
// Check if the URL query param has ?session=expired
const query = new URLSearchParams(location.search); const query = new URLSearchParams(location.search);
if (query.get('session') === 'expired') { if (query.get('session') === 'expired') {
setShowSessionExpiredMsg(true); setShowSessionExpiredMsg(true);
@ -24,71 +23,60 @@ function SignIn({ setIsAuthenticated, setUser }) {
event.preventDefault(); event.preventDefault();
setError(''); setError('');
// 0⃣ clear everything that belongs to the *previous* user // Clean slate from any prior user/session (keeps your allowlist style)
localStorage.removeItem('careerSuggestionsCache'); safeLocal.clearMany([
localStorage.removeItem('lastSelectedCareerProfileId'); 'id',
localStorage.removeItem('aiClickCount'); 'careerSuggestionsCache',
localStorage.removeItem('aiClickDate');
localStorage.removeItem('aiRecommendations');
localStorage.removeItem('premiumOnboardingState');
localStorage.removeItem('financialProfile'); // if you cache it
localStorage.removeItem('selectedScenario');
const username = usernameRef.current.value;
const password = passwordRef.current.value;
if (!username || !password) {
setError('Please enter both username and password');
return;
}
try {
const resp = await fetch('/api/signin', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({username, password}),
});
const data = await resp.json(); // ← read ONCE
if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
/* ---------------- success path ---------------- */
const { token, id, user } = data;
// fetch current user profile immediately
const profileRes = await fetch('/api/user-profile', {
headers: { Authorization: `Bearer ${token}` }
});
const profile = await profileRes.json();
setFinancialProfile(profile);
setScenario(null); // or fetch latest scenario separately
/* purge any leftovers from prior session */
['careerSuggestionsCache',
'lastSelectedCareerProfileId', 'lastSelectedCareerProfileId',
'aiClickCount', 'aiClickCount',
'aiClickDate', 'aiClickDate',
'aiRecommendations', 'aiRecommendations',
'premiumOnboardingState', 'premiumOnboardingState',
'financialProfile', 'financialProfile',
'selectedScenario' 'selectedScenario',
].forEach(k => localStorage.removeItem(k)); ]);
/* store new session data */ const username = usernameRef.current.value;
localStorage.setItem('token', token); const password = passwordRef.current.value;
localStorage.setItem('id', id); if (!username || !password) {
setError('Please enter both username and password');
return;
}
try {
// Server should set an HttpOnly, SameSite cookie here
const resp = await fetch('/api/signin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include', // <-- important for cookie-based auth
body: JSON.stringify({ username, password }),
});
const data = await resp.json();
if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
// Optional: keep allowlisted id if provided by API
if (data.id) localStorage.setItem('id', data.id);
// 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); setIsAuthenticated(true);
setUser(user); setUser(data.user || null);
navigate('/signin-landing'); navigate('/signin-landing');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message || 'Sign-in failed');
} }
}; };
return ( return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4"> <div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
{showSessionExpiredMsg && ( {showSessionExpiredMsg && (
<div className="mb-4 p-2 bg-red-100 border border-red-300 text-red-700 rounded"> <div className="mb-4 p-2 bg-red-100 border border-red-300 text-red-700 rounded">
@ -110,12 +98,14 @@ function SignIn({ setIsAuthenticated, setUser }) {
placeholder="Username" placeholder="Username"
ref={usernameRef} ref={usernameRef}
className="w-full rounded border border-gray-300 p-2 focus:border-blue-500 focus:outline-none" className="w-full rounded border border-gray-300 p-2 focus:border-blue-500 focus:outline-none"
autoComplete="username"
/> />
<input <input
type="password" type="password"
placeholder="Password" placeholder="Password"
ref={passwordRef} ref={passwordRef}
className="w-full rounded border border-gray-300 p-2 focus:border-blue-500 focus:outline-none" className="w-full rounded border border-gray-300 p-2 focus:border-blue-500 focus:outline-none"
autoComplete="current-password"
/> />
<button <button
type="submit" type="submit"

View File

@ -12,51 +12,73 @@ function UserProfile() {
const [selectedArea, setSelectedArea] = useState(''); const [selectedArea, setSelectedArea] = useState('');
const [careerSituation, setCareerSituation] = useState(''); const [careerSituation, setCareerSituation] = useState('');
const [loadingAreas, setLoadingAreas] = useState(false); const [loadingAreas, setLoadingAreas] = useState(false);
const [isPremiumUser, setIsPremiumUser] = useState(false);
const [phoneE164, setPhoneE164] = useState(''); const [phoneE164, setPhoneE164] = useState('');
const [smsOptIn, setSmsOptIn] = useState(false); const [smsOptIn, setSmsOptIn] = useState(false);
const [showChangePw, setShowChangePw] = useState(false); const [showChangePw, setShowChangePw] = useState(false);
// Subscription state
const [sub, setSub] = useState(null);
const isPremium = !!(sub?.is_premium || sub?.is_pro_premium);
const planLabel = sub?.is_pro_premium ? 'Pro Premium' : sub?.is_premium ? 'Premium' : 'Free';
const navigate = useNavigate(); const navigate = useNavigate();
// Helper to do authorized fetch // Cookie-auth helper: include credentials; if unauthorized, bounce to sign-in
const authFetch = async (url, options = {}) => { const authFetch = async (url, options = {}) => {
const token = localStorage.getItem('token');
if (!token) {
navigate('/signin');
return null;
}
const res = await fetch(url, { const res = await fetch(url, {
credentials: 'include',
...options, ...options,
headers: { headers: {
Authorization: `Bearer ${token}`, Accept: 'application/json',
'Content-Type': 'application/json', ...(options.headers || {}),
...options.headers,
}, },
}); });
if (res.status === 401 || res.status === 419) {
if ([401, 403].includes(res.status)) { navigate('/signin?session=expired');
console.warn('Token invalid or expired. Redirecting to Sign In.');
navigate('/signin');
return null; return null;
} }
return res; return res;
}; };
// --- Subscription helpers ---
const loadSubStatus = async () => {
const r = await authFetch('/api/premium/subscription/status', { method: 'GET' });
if (!r || !r.ok) { setSub({ is_premium: 0, is_pro_premium: 0 }); return; }
setSub(await r.json());
};
const openPortal = async () => {
const returnUrl = `${window.location.origin}/user-profile?portal=done`;
const r = await authFetch(
`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(returnUrl)}`
);
if (!r || !r.ok) {
try { console.error('Portal error', r && (await r.text())); } catch {}
return;
}
const { url } = await r.json();
window.location.href = url;
};
useEffect(() => { loadSubStatus(); }, []);
// When returning from Stripe portal, refresh status and clean the query param
useEffect(() => { useEffect(() => {
const fetchProfileAndAreas = async () => { const url = new URL(window.location.href);
if (url.searchParams.get('portal') === 'done') {
loadSubStatus();
url.searchParams.delete('portal');
window.history.replaceState({}, '', url.toString());
}
}, []);
// Load profile and prefetch areas
useEffect(() => {
(async () => {
try { try {
const token = localStorage.getItem('token'); const res = await authFetch('/api/user-profile', { method: 'GET' });
if (!token) return;
const res = await authFetch('/api/user-profile', {
method: 'GET',
});
if (!res || !res.ok) return; if (!res || !res.ok) return;
const data = await res.json(); const data = await res.json();
setFirstName(data.firstname || ''); setFirstName(data.firstname || '');
@ -69,20 +91,13 @@ function UserProfile() {
setPhoneE164(data.phone_e164 || ''); setPhoneE164(data.phone_e164 || '');
setSmsOptIn(!!data.sms_opt_in); setSmsOptIn(!!data.sms_opt_in);
if (data.is_premium === 1) {
setIsPremiumUser(true);
}
// If we have a state, load its areas
if (data.state) { if (data.state) {
setLoadingAreas(true); setLoadingAreas(true);
try { try {
const areaRes = await authFetch(`/api/areas?state=${data.state}`); const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(data.state)}`);
if (!areaRes || !areaRes.ok) { if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas');
throw new Error('Failed to fetch areas');
}
const areaData = await areaRes.json(); const areaData = await areaRes.json();
setAreas(areaData.areas); setAreas(areaData.areas || []);
} catch (areaErr) { } catch (areaErr) {
console.error('Error fetching areas:', areaErr); console.error('Error fetching areas:', areaErr);
setAreas([]); setAreas([]);
@ -93,34 +108,18 @@ function UserProfile() {
} catch (error) { } catch (error) {
console.error('Error loading user profile:', error); console.error('Error loading user profile:', error);
} }
}; })();
fetchProfileAndAreas();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // only runs once }, []);
// Whenever user changes "selectedState", re-fetch areas // Refetch areas when state changes
useEffect(() => { useEffect(() => {
const fetchAreasByState = async () => { (async () => {
if (!selectedState) { if (!selectedState) { setAreas([]); return; }
setAreas([]);
return;
}
setLoadingAreas(true); setLoadingAreas(true);
try { try {
const token = localStorage.getItem('token'); const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(selectedState)}`);
if (!token) return; if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas');
const areaRes = await fetch(`/api/areas?state=${selectedState}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!areaRes.ok) {
throw new Error('Failed to fetch areas');
}
const areaData = await areaRes.json(); const areaData = await areaRes.json();
setAreas(areaData.areas || []); setAreas(areaData.areas || []);
} catch (error) { } catch (error) {
@ -129,14 +128,12 @@ function UserProfile() {
} finally { } finally {
setLoadingAreas(false); setLoadingAreas(false);
} }
}; })();
// eslint-disable-next-line react-hooks/exhaustive-deps
fetchAreasByState();
}, [selectedState]); }, [selectedState]);
const handleFormSubmit = async (e) => { const handleFormSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
const profileData = { const profileData = {
firstName, firstName,
lastName, lastName,
@ -146,25 +143,22 @@ function UserProfile() {
area: selectedArea, area: selectedArea,
careerSituation, careerSituation,
phone_e164: phoneE164 || null, phone_e164: phoneE164 || null,
sms_opt_in: !!smsOptIn sms_opt_in: !!smsOptIn,
}; };
try { try {
const response = await authFetch('/api/user-profile', { const response = await authFetch('/api/user-profile', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(profileData), body: JSON.stringify(profileData),
}); });
if (!response || !response.ok) throw new Error('Failed to save profile');
if (!response || !response.ok) {
throw new Error('Failed to save profile');
}
console.log('Profile saved successfully'); console.log('Profile saved successfully');
} catch (error) { } catch (error) {
console.error('Error saving profile:', error); console.error('Error saving profile:', error);
} }
}; };
// FULL list of states for your dropdown
const states = [ const states = [
{ name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' }, { name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' },
{ name: 'Arkansas', code: 'AR' }, { name: 'California', code: 'CA' }, { name: 'Colorado', code: 'CO' }, { name: 'Arkansas', code: 'AR' }, { name: 'California', code: 'CA' }, { name: 'Colorado', code: 'CO' },
@ -185,24 +179,11 @@ function UserProfile() {
{ name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' }, { name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' },
]; ];
// The updated career situations (same as in SignUp.js)
const careerSituations = [ const careerSituations = [
{ { id: 'planning', title: 'Planning Your Career' },
id: 'planning', { id: 'preparing', title: 'Preparing for Your (Next) Career' },
title: 'Planning Your Career', { id: 'enhancing', title: 'Enhancing Your Career' },
}, { id: 'retirement', title: 'Retirement Planning' },
{
id: 'preparing',
title: 'Preparing for Your (Next) Career',
},
{
id: 'enhancing',
title: 'Enhancing Your Career',
},
{
id: 'retirement',
title: 'Retirement Planning',
},
]; ];
return ( return (
@ -213,9 +194,7 @@ function UserProfile() {
<form onSubmit={handleFormSubmit} className="space-y-4"> <form onSubmit={handleFormSubmit} className="space-y-4">
{/* First Name */} {/* First Name */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">First Name:</label>
First Name:
</label>
<input <input
type="text" type="text"
value={firstName} value={firstName}
@ -227,9 +206,7 @@ function UserProfile() {
{/* Last Name */} {/* Last Name */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">Last Name:</label>
Last Name:
</label>
<input <input
type="text" type="text"
value={lastName} value={lastName}
@ -241,9 +218,7 @@ function UserProfile() {
{/* Email */} {/* Email */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">Email:</label>
Email:
</label>
<input <input
type="email" type="email"
value={email} value={email}
@ -253,11 +228,9 @@ function UserProfile() {
/> />
</div> </div>
{/* ZIP Code */} {/* ZIP */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">ZIP Code:</label>
ZIP Code:
</label>
<input <input
type="text" type="text"
value={zipCode} value={zipCode}
@ -267,11 +240,9 @@ function UserProfile() {
/> />
</div> </div>
{/* State Dropdown */} {/* State */}
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">State:</label>
State:
</label>
<select <select
value={selectedState} value={selectedState}
onChange={(e) => setSelectedState(e.target.value)} onChange={(e) => setSelectedState(e.target.value)}
@ -280,24 +251,17 @@ function UserProfile() {
> >
<option value="">Select a State</option> <option value="">Select a State</option>
{states.map((s) => ( {states.map((s) => (
<option key={s.code} value={s.code}> <option key={s.code} value={s.code}>{s.name}</option>
{s.name}
</option>
))} ))}
</select> </select>
</div> </div>
{/* Loading indicator for areas */} {/* Areas */}
{loadingAreas && ( {loadingAreas && <p className="text-sm text-gray-500">Loading areas...</p>}
<p className="text-sm text-gray-500">Loading areas...</p>
)}
{/* Areas Dropdown */}
{!loadingAreas && areas.length > 0 && ( {!loadingAreas && areas.length > 0 && (
<div> <div>
<label className="mb-1 block text-sm font-medium text-gray-700"> <label className="mb-1 block text-sm font-medium text-gray-700">Area:</label>
Area:
</label>
<select <select
value={selectedArea} value={selectedArea}
onChange={(e) => setSelectedArea(e.target.value)} onChange={(e) => setSelectedArea(e.target.value)}
@ -305,15 +269,14 @@ function UserProfile() {
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-blue-600 focus:outline-none" className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-blue-600 focus:outline-none"
> >
<option value="">Select an Area</option> <option value="">Select an Area</option>
{areas.map((area, index) => ( {areas.map((area, idx) => (
<option key={index} value={area}> <option key={idx} value={area}>{area}</option>
{area}
</option>
))} ))}
</select> </select>
</div> </div>
)} )}
{/* Phone + SMS */}
<div className="mt-4"> <div className="mt-4">
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label> <label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
<input <input
@ -345,17 +308,16 @@ function UserProfile() {
> >
<option value="">Select One</option> <option value="">Select One</option>
{careerSituations.map((cs) => ( {careerSituations.map((cs) => (
<option key={cs.id} value={cs.id}> <option key={cs.id} value={cs.id}>{cs.title}</option>
{cs.title}
</option>
))} ))}
</select> </select>
</div> </div>
{/* Password */}
<div className="mt-8"> <div className="mt-8">
<button <button
type="button" type="button"
onClick={() => setShowChangePw(s => !s)} onClick={() => setShowChangePw((s) => !s)}
className="rounded border px-3 py-2 text-sm hover:bg-gray-100" className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
> >
{showChangePw ? 'Cancel password change' : 'Change password'} {showChangePw ? 'Cancel password change' : 'Change password'}
@ -368,7 +330,49 @@ function UserProfile() {
)} )}
</div> </div>
{/* Form Buttons */} {/* Subscription */}
<div className="mt-6 rounded border p-4">
<h3 className="mb-2 text-lg font-semibold">Subscription</h3>
{sub === null ? (
<p className="text-sm text-gray-500">Loading subscription</p>
) : (
<>
<p className="text-sm">
Current plan: <strong>{planLabel}</strong>
{sub?.cancel_at_period_end && (
<span className="ml-2 text-amber-700">
(Scheduled to end {new Date(sub.current_period_end).toLocaleDateString()})
</span>
)}
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={openPortal}
className="rounded bg-blue-600 px-3 py-2 text-white hover:bg-blue-700"
>
Manage subscription
</button>
<button
type="button"
onClick={loadSubStatus}
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
>
Refresh status
</button>
</div>
{!isPremium && (
<p className="mt-2 text-sm text-gray-600">
Premium features are disabled. Re-subscribe anytime; your data is preserved.
</p>
)}
</>
)}
</div>
{/* Actions */}
<div className="mt-6 flex items-center justify-end space-x-3"> <div className="mt-6 flex items-center justify-end space-x-3">
<button <button
type="submit" type="submit"

View File

@ -1,4 +1,4 @@
import axios from 'axios'; import api from '../auth/apiClient.js';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
const careersFile = '../../updated_career_data_final.json'; const careersFile = '../../updated_career_data_final.json';
@ -22,7 +22,7 @@ const mapScoreToScale = (score) => {
// Fully corrected function to fetch ratings from O*Net API // Fully corrected function to fetch ratings from O*Net API
const fetchCareerRatingsCorrected = async (socCode) => { const fetchCareerRatingsCorrected = async (socCode) => {
try { try {
const response = await axios.get( const response = await api.get(
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`, `https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
{ auth: { username: onetUsername, password: onetPassword } } { auth: { username: onetUsername, password: onetPassword } }
); );

View File

@ -2,15 +2,17 @@ import axios from 'axios';
export async function clientGeocodeZip(zip) { export async function clientGeocodeZip(zip) {
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY; const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${apiKey}`; if (!apiKey) throw new Error('REACT_APP_GOOGLE_MAPS_API_KEY is not set at build time.');
const resp = await axios.get(url); const url =
if (resp.data.status === 'OK' && resp.data.results && resp.data.results.length > 0) { `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${encodeURIComponent(apiKey)}`;
const resp = await axios.get(url, { withCredentials: false });
if (resp.data.status === 'OK' && resp.data.results?.length > 0) {
return resp.data.results[0].geometry.location; // { lat, lng } return resp.data.results[0].geometry.location; // { lat, lng }
} }
throw new Error('Geocoding failed.'); throw new Error(`Geocoding failed: ${resp.data.status || 'Unknown'}.`);
} }
// utils/apiUtils.js // utils/apiUtils.js
export function haversineDistance(lat1, lon1, lat2, lon2) { export function haversineDistance(lat1, lon1, lat2, lon2) {

View File

@ -1,3 +1,8 @@
// Cookie-based auth fetch used across the app.
// - Does NOT read from localStorage.
// - Sends cookies automatically (credentials: 'include').
// - Keeps the same behavior: return Response, or null on 401/403.
let onSessionExpiredCallback = null; let onSessionExpiredCallback = null;
export const setSessionExpiredCallback = (callback) => { export const setSessionExpiredCallback = (callback) => {
@ -5,26 +10,21 @@ export const setSessionExpiredCallback = (callback) => {
}; };
const authFetch = async (url, options = {}) => { const authFetch = async (url, options = {}) => {
const token = localStorage.getItem('token'); const method = (options.method || 'GET').toUpperCase();
const hasCTHeader = options.headers && Object.prototype.hasOwnProperty.call(options.headers, 'Content-Type');
if (!token) { const shouldIncludeContentType = ['POST','PUT','PATCH'].includes(method) && !hasCTHeader;
onSessionExpiredCallback?.();
return null;
}
const method = options.method?.toUpperCase() || 'GET';
const shouldIncludeContentType = ['POST', 'PUT', 'PATCH'].includes(method);
const res = await fetch(url, { const res = await fetch(url, {
credentials: 'include', // <-- send httpOnly session cookie
...options, ...options,
headers: { headers: {
Authorization: `Bearer ${token}`, ...(shouldIncludeContentType ? { 'Content-Type': 'application/json' } : {}),
...(shouldIncludeContentType && { 'Content-Type': 'application/json' }), Accept: 'application/json',
...options.headers, ...(options.headers || {}),
}, },
}); });
if ([401, 403].includes(res.status)) { if (res.status === 401 || res.status === 403) {
onSessionExpiredCallback?.(); onSessionExpiredCallback?.();
return null; return null;
} }

View File

@ -1,3 +1,5 @@
import api from '../auth/apiClient.js';
// ============= handleCareerClick ============= // ============= handleCareerClick =============
const handleCareerClick = useCallback( const handleCareerClick = useCallback(
async (career) => { async (career) => {
@ -34,7 +36,7 @@
// Salary // Salary
let salaryResponse; let salaryResponse;
try { try {
salaryResponse = await axios.get('/api/salary', { salaryResponse = await api.get('/api/salary', {
params: { socCode: socCode.split('.')[0], area: areaTitle }, params: { socCode: socCode.split('.')[0], area: areaTitle },
}); });
} catch (error) { } catch (error) {
@ -46,7 +48,7 @@
// Economic // Economic
let economicResponse; let economicResponse;
try { try {
economicResponse = await axios.get(`api/projections/${socCode.split('.')[0]}`, { economicResponse = await api.get(`api/projections/${socCode.split('.')[0]}`, {
params: { state: fullName }, // e.g. "Kentucky" params: { state: fullName }, // e.g. "Kentucky"
}); });
} catch (error) { } catch (error) {
@ -56,7 +58,7 @@
// Tuition // Tuition
let tuitionResponse; let tuitionResponse;
try { try {
tuitionResponse = await axios.get('/api/tuition', { tuitionResponse = await api.get('/api/tuition', {
params: { cipCode: cleanedCipCode, state: userState }, params: { cipCode: cleanedCipCode, state: userState },
}); });
} catch (error) { } catch (error) {

View File

@ -0,0 +1,30 @@
// src/utils/onboardingDraftApi.js
import authFetch from './authFetch.js';
const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, '');
const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`;
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 saveDraft({ id = null, step = 0, data = {} } = {}) {
const res = await authFetch(DRAFT_URL, {
method: 'POST',
body: JSON.stringify({ id, step, data }),
});
if (!res) return null;
if (!res.ok) throw new Error(`saveDraft ${res.status}`);
return res.json(); // { id, step }
}
export async function clearDraft() {
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
if (!res) return false;
if (!res.ok) throw new Error(`clearDraft ${res.status}`);
return true; // server returns { ok: true }
}

View File

@ -0,0 +1,9 @@
// src/utils/onboardingGuard.js
export function isOnboardingInProgress() {
try {
const ptr = JSON.parse(localStorage.getItem('premiumOnboardingPointer') || '{}');
if (!Number.isInteger(ptr.step)) return false;
const lastStepIndex = ptr.skipFin ? 3 : 4; // Welcome, Career, (Fin), College, Review
return ptr.step < lastStepIndex;
} catch { return false; }
}

View File

@ -1,21 +1,65 @@
// safeLocal.js // src/utils/safeLocal.js
const NS = 'aptiva:'; const NS = 'aptiva:';
// Whitelist + TTL (ms)
const ALLOW = { const ALLOW = {
theme: 30*24*3600*1000, // 30d theme: 30 * 24 * 3600 * 1000, // 30d
layoutMode: 30*24*3600*1000, // 30d layoutMode: 30 * 24 * 3600 * 1000, // 30d
lastPanel: 24*3600*1000, // 1d lastPanel: 1 * 24 * 3600 * 1000, // 1d
flagsVersion: 6*3600*1000, // 6h flagsVersion: 6 * 3600 * 1000, // 6h
lastCareerName: 24*3600*1000, // 1d lastCareerName: 1 * 24 * 3600 * 1000, // 1d
}; };
function _ns(k) { return `${NS}${k}`; }
// Strict setter: only allow whitelisted keys
export function setItem(key, value) { export function setItem(key, value) {
if (!(key in ALLOW)) throw new Error(`[safeLocal] Not allowed: ${key}`); if (!(key in ALLOW)) throw new Error(`[safeLocal] Not allowed: ${key}`);
const exp = Date.now() + (ALLOW[key] || 0); const exp = Date.now() + (ALLOW[key] || 0);
localStorage.setItem(NS + key, JSON.stringify({ v: value, e: exp })); try {
localStorage.setItem(_ns(key), JSON.stringify({ v: value, e: exp }));
} catch {}
} }
// Getter: enforces TTL; returns null if missing/expired/not allowed
export function getItem(key) { export function getItem(key) {
if (!(key in ALLOW)) return null; if (!(key in ALLOW)) return null;
const raw = localStorage.getItem(NS + key); if (!raw) return null; try {
try { const { v, e } = JSON.parse(raw); if (e && e < Date.now()) { localStorage.removeItem(NS + key); return null; } return v; } const raw = localStorage.getItem(_ns(key));
catch { localStorage.removeItem(NS + key); return null; } if (!raw) return null;
const { v, e } = JSON.parse(raw);
if (e && e < Date.now()) {
localStorage.removeItem(_ns(key));
return null;
}
return v;
} catch {
try { localStorage.removeItem(_ns(key)); } catch {}
return null;
}
} }
export function removeItem(key) { localStorage.removeItem(NS + key); }
// Remove one key (tries namespaced; also best-effort raw for legacy cleanup)
export function removeItem(key) {
try { localStorage.removeItem(_ns(key)); } catch {}
try { localStorage.removeItem(key); } catch {}
}
// Remove many keys (safe to pass unknown/legacy keys)
export function clearMany(keys = []) {
for (const k of keys) {
try { localStorage.removeItem(_ns(k)); } catch {}
try { localStorage.removeItem(k); } catch {}
}
}
// Optional helper to purge all whitelisted keys at once
export function clearAllAllowed() {
for (const k of Object.keys(ALLOW)) {
try { localStorage.removeItem(_ns(k)); } catch {}
}
}
// Default export (so `import safeLocal from ...` works)
const safeLocal = { setItem, getItem, removeItem, clearMany, clearAllAllowed };
export default safeLocal;

View File

@ -1,23 +1,52 @@
// storageGuard.js // src/utils/storageGuard.js
const RESTRICTED_SUBSTRINGS = [ // Blocks obviously sensitive keys from being written to Web Storage.
'token','access','refresh','userid','user_id','user','profile','email','phone', // Shows a loud console error so you catch any accidental writes.
'answers','interest','riasec','salary','ssn','auth'
]; const SENSITIVE_KEYS = new Set([
function shouldBlock(key) { 'token',
const k = String(key || '').toLowerCase(); 'accessToken',
return RESTRICTED_SUBSTRINGS.some(s => k.includes(s)); 'refreshToken',
'aptiva_access',
'aptiva_access_token',
'idToken',
'id_token',
'authorization',
'financialProfile',
'premiumOnboardingState',
]);
function looksLikeJwt(str = '') {
// quick-and-safe: three dot-separated base64-ish segments
return typeof str === 'string' && str.split('.').length === 3 && str.length > 24;
} }
function wrap(storage) {
if (!storage) return; export function installStorageGuard({ noisy = true } = {}) {
const _set = storage.setItem.bind(storage); if (typeof window === 'undefined' || !window.localStorage) return () => {};
storage.setItem = (k, v) => {
if (shouldBlock(k)) { const origSetItem = Storage.prototype.setItem;
throw new Error(`[storageGuard] Blocked setItem(\"${k}\"). Sensitive data is not allowed in Web Storage.`);
Storage.prototype.setItem = function guardedSetItem(key, value) {
try {
const k = String(key || '');
const v = String(value || '');
if (SENSITIVE_KEYS.has(k) || looksLikeJwt(v) || /bearer\s+/i.test(v)) {
if (noisy) {
console.error(
`[storageGuard] Blocked setItem("${k}"). ` +
`Sensitive data is not allowed in Web Storage.`
);
} }
return _set(k, v); return; // block write
}
} catch {
// fall through to original on any unexpected error
}
return origSetItem.apply(this, arguments);
};
// return an uninstall function in case you ever want to restore it
return () => {
Storage.prototype.setItem = origSetItem;
}; };
} }
export function installStorageGuard() {
try { wrap(window.localStorage); } catch {}
try { wrap(window.sessionStorage); } catch {}
}

View File

@ -1,4 +1,4 @@
import axios from 'axios'; import api from '../auth/apiClient.js';;
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
const testOutputFile = '../../test_careers_with_ratings.json'; const testOutputFile = '../../test_careers_with_ratings.json';
@ -21,7 +21,7 @@ const mapScoreToScale = (score) => {
const fetchCareerRatingsTest = async (socCode) => { const fetchCareerRatingsTest = async (socCode) => {
try { try {
const response = await axios.get( const response = await api.get(
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`, `https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
{ auth: { username: onetUsername, password: onetPassword } } { auth: { username: onetUsername, password: onetPassword } }
); );

View File

@ -1,25 +0,0 @@
import axios from 'axios';
import dotenv from 'dotenv';
// Load environment variables from .env file
dotenv.config();
// Base64 encode username and password for Basic Authentication
const authToken = Buffer.from(`${process.env.ONET_USERNAME}:${process.env.ONET_PASSWORD}`).toString('base64');
// Define the API endpoint to test
const url = 'https://services.onetcenter.org/ws/mnm/interestprofiler/questions?start=1&end=5';
// Send a GET request with Authorization header
axios.get(url, {
headers: {
'Authorization': `Basic ${authToken}`,
'Accept': 'application/json'
}
})
.then(response => {
console.log('API Response:', response.data);
})
.catch(error => {
console.error('Error:', error.response ? error.response.data : error.message);
});