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
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; \
SUPPORT_SENDGRID_API_KEY=$(gcloud secrets versions access latest --secret=SUPPORT_SENDGRID_API_KEY_$ENV --project=$PROJECT); \
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; \
\
# ── DEK sync: copy dev wrapped DEK into staging volume path ── \
@ -127,9 +129,9 @@ steps:
fi; \
\
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; \
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; \
echo "✅ Staging stack refreshed with tag $IMG_TAG"'

View File

@ -1,47 +1,39 @@
// backend/jobs/reminderCron.js
import cron from 'node-cron';
import pool from '../config/mysqlPool.js';
import { sendSMS } from '../utils/smsService.js';
import { query } from '../shared/db/withEncryption.js';
import cron from 'node-cron';
import pool from '../config/mysqlPool.js';
import { sendSMS } from '../utils/smsService.js';
const BATCH_SIZE = 25; // tune as you like
const BATCH_SIZE = 25;
/* Every minute */
cron.schedule('*/1 * * * *', async () => {
try {
/* 1⃣ Fetch at most BATCH_SIZE reminders that are due */
const [rows] = await pool.query(
`SELECT id,
phone_e164 AS toNumber,
message_body AS body
FROM reminders
WHERE status = 'pending'
AND send_at_utc <= UTC_TIMESTAMP()
ORDER BY send_at_utc ASC
LIMIT ?`,
[BATCH_SIZE]
);
// IMPORTANT: use execute() so the param is truly bound
if (!rows.length) return; // nothing to do
const [rows] = await pool.execute(
`SELECT id,
phone_e164 AS toNumber,
message_body AS body
FROM reminders
WHERE status = 'pending'
AND send_at_utc <= UTC_TIMESTAMP()
ORDER BY send_at_utc ASC
LIMIT ?`,
[BATCH_SIZE] // must be a number
);
if (!rows.length) return;
let sent = 0, failed = 0;
/* 2⃣ Fire off each SMS (sendSMS handles its own DB status update) */
for (const r of rows) {
try {
await sendSMS({ // ← updated signature
reminderId: r.id,
to : r.toNumber,
body : r.body
});
await sendSMS({ reminderId: r.id, to: r.toNumber, body: r.body });
sent++;
} catch (err) {
console.error('[reminderCron] Twilio error:', err?.message || err);
failed++;
/* sendSMS already logged the failure + updated status */
}
}
console.log(`[reminderCron] processed ${rows.length}: ${sent} sent, ${failed} failed`);
} catch (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 { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js';
import cookieParser from 'cookie-parser';
const CANARY_SQL = `
CREATE TABLE IF NOT EXISTS encryption_canary (
@ -85,16 +86,10 @@ try {
const app = express();
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.use(bodyParser.json());
app.use(express.json());
app.set('trust proxy', 1); // important if you're behind a proxy/HTTPS terminator
app.use(cookieParser());
app.use(
helmet({
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() {
const p = (process.env.DEK_PATH || '').trim();
return p ? path.join(path.dirname(p), 'dek.fpr') : null;
@ -223,13 +241,12 @@ app.use(
app.options('*', (req, res) => {
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
);
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With');
res.setHeader('Access-Control-Allow-Credentials', 'true'); // <-- add this
res.status(200).end();
});
// Add HTTP headers for security
app.use((req, res, next) => {
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]);
const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
return res.status(201).json({
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({
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 fs from 'fs';
import { readFile } from 'fs/promises'; // <-- add this
import readline from 'readline';
import chatFreeEndpoint from "./utils/chatFreeEndpoint.js";
import { OpenAI } from 'openai';
import rateLimit from 'express-rate-limit';
import authenticateUser from './utils/authenticateUser.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 crypto from 'crypto';
import cookieParser from 'cookie-parser';
import { v4 as uuid } from 'uuid';
const __filename = fileURLToPath(import.meta.url);
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 SALARY_DB_PATH = path.join(ROOT_DIR, 'salary_info.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]) {
if (!fs.existsSync(p)) {
@ -72,6 +74,7 @@ try {
// Create Express app
const app = express();
const PORT = process.env.SERVER2_PORT || 5001;
app.use(cookieParser());
function fprPathFromEnv() {
const p = (process.env.DEK_PATH || '').trim();
@ -219,10 +222,7 @@ async function initDatabases() {
}
}
await initDatabases();
// …rest of your routes and app.listen(PORT)
await initDatabases();
/*
* SECURITY, CORS, JSON Body
@ -507,11 +507,12 @@ app.post('/api/onet/submit_answers', async (req, res) => {
console.error('Invalid answers:', answers);
return res.status(400).json({ error: 'Answers must be 60 chars long.' });
}
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}`;
// career suggestions
// O*NET calls → Basic Auth only
const careerResponse = await axios.get(careerUrl, {
auth: {
username: process.env.ONET_USERNAME,
@ -519,7 +520,7 @@ app.post('/api/onet/submit_answers', async (req, res) => {
},
headers: { Accept: 'application/json' },
});
// RIASEC
const resultsResponse = await axios.get(resultsUrl, {
auth: {
username: process.env.ONET_USERNAME,
@ -529,26 +530,29 @@ app.post('/api/onet/submit_answers', async (req, res) => {
});
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];
if (token) {
// Pass the caller's Bearer straight through to server1 (if present)
const bearer = req.headers.authorization; // e.g. "Bearer eyJ..."
if (bearer) {
try {
await axios.post('/api/user-profile',
await axios.post(
`${API_BASE}/api/user-profile`,
{
interest_inventory_answers: answers,
riasec: riasecCode
riasec: riasecCode,
},
{ headers: { Authorization: `Bearer ${token}` } }
{ headers: { Authorization: bearer } }
);
} catch (err) {
console.error('Error storing RIASEC in user_profile =>', err.response?.data || err.message);
// fallback if needed
console.error(
'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) {
return careers
.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
**************************************************/

View File

@ -16,6 +16,7 @@ import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import pkg from 'pdfjs-dist';
import pool from './config/mysqlPool.js';
import { v4 as uuid } from 'uuid';
import OpenAI from 'openai';
import Fuse from 'fuse.js';
@ -24,6 +25,7 @@ import { createReminder } from './utils/smsService.js';
import { initEncryption, verifyCanary } from './shared/crypto/encryption.js';
import { hashForLookup } from './shared/crypto/encryption.js';
import cookieParser from 'cookie-parser';
import './jobs/reminderCron.js';
import { cacheSummary } from "./utils/ctxCache.js";
@ -57,6 +59,7 @@ function isSafeRedirect(url) {
}
const app = express();
app.use(cookieParser());
const { getDocument } = pkg;
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(
'/api/premium/stripe/webhook',
express.raw({ type: 'application/json' }),
@ -293,12 +325,12 @@ app.use((req, res, next) => {
'Access-Control-Allow-Headers',
'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)
} else {
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(
'Access-Control-Allow-Headers',
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
@ -312,20 +344,17 @@ app.use((req, res, next) => {
});
// 3) Authentication middleware
const authenticatePremiumUser = (req, res, next) => {
const token = (req.headers.authorization || '')
.replace(/^Bearer\s+/i, '') // drop “Bearer ”
.trim(); // strip CR/LF, spaces
if (!token) {
return res.status(401).json({ error: 'Premium authorization required' });
}
function authenticatePremiumUser(req, res, next) {
let token = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
if (!token) token = req.cookies?.[COOKIE_NAME] || req.cookies?.token || '';
if (!token) return res.status(401).json({ error: 'Premium authorization required' });
try {
const JWT_SECRET = process.env.JWT_SECRET;
const { id } = jwt.verify(token, JWT_SECRET);
req.id = id; // store user ID in request
const { id } = jwt.verify(token, process.env.JWT_SECRET);
req.id = id;
next();
} catch (error) {
} catch {
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) => {
try {
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) => {
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_district ? 1 : 0,
college_enrollment_status || null,
annual_financial_aid || 0,
annual_financial_aid ?? null,
is_online ? 1 : 0,
credit_hours_per_year || 0,
credit_hours_per_year ?? null,
hours_completed || 0,
program_length || 0,
credit_hours_required || 0,
@ -3003,7 +3008,8 @@ app.get('/api/premium/college-profile', authenticatePremiumUser, async (req, res
LIMIT 1
`, [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) {
console.error('Error fetching college profile:', error);
res.status(500).json({ error: 'Failed to fetch college profile.' });
@ -3017,8 +3023,10 @@ app.get('/api/premium/college-profile/all', authenticatePremiumUser, async (req,
DATE_FORMAT(cp.created_at,'%Y-%m-%d') AS created_at,
IFNULL(cpr.scenario_title, cpr.career_name) AS career_title
FROM college_profiles cp
JOIN career_profiles cpr ON cpr.id = cp.career_profile_id
WHERE cp.user_id = ?
JOIN career_profiles cpr
ON cpr.id = cp.career_profile_id
AND cpr.user_id = cp.user_id
WHERE cp.user_id = ?
ORDER BY cp.created_at DESC
`;
const [rows] = await pool.query(sql,[req.id]);
@ -4090,54 +4098,66 @@ app.post('/api/premium/reminders', authenticatePremiumUser, async (req, res) =>
app.post('/api/premium/stripe/create-checkout-session',
authenticatePremiumUser,
async (req, res) => {
const { tier = 'premium', cycle = 'monthly', success_url, cancel_url } =
req.body || {};
try {
const { tier = 'premium', cycle = 'monthly', success_url, cancel_url } = req.body || {};
const priceId = priceMap?.[tier]?.[cycle];
if (!priceId) return res.status(400).json({ error: 'Bad tier or cycle' });
const priceId = priceMap?.[tier]?.[cycle];
if (!priceId) return res.status(400).json({ error: 'bad_tier_or_cycle' });
const customerId = await getOrCreateStripeCustomerId(req);
const customerId = await getOrCreateStripeCustomerId(req);
const base = PUBLIC_BASE || `https://${req.headers.host}`;
const defaultSuccess = `${base}/billing?ck=success`;
const defaultCancel = `${base}/billing?ck=cancel`;
const base = PUBLIC_BASE || `https://${req.headers.host}`;
const defaultSuccess = `${base}/billing?ck=success`;
const defaultCancel = `${base}/billing?ck=cancel`;
const safeSuccess = success_url && isSafeRedirect(success_url)
? success_url : defaultSuccess;
const safeCancel = cancel_url && isSafeRedirect(cancel_url)
? cancel_url : defaultCancel;
const safeSuccess = success_url && isSafeRedirect(success_url) ? success_url : defaultSuccess;
const safeCancel = cancel_url && isSafeRedirect(cancel_url) ? cancel_url : defaultCancel;
const session = await stripe.checkout.sessions.create({
mode : 'subscription',
customer : customerId,
line_items : [{ price: priceId, quantity: 1 }],
allow_promotion_codes : true,
success_url : safeSuccess,
cancel_url : safeCancel
});
const session = await stripe.checkout.sessions.create({
mode : 'subscription',
customer : customerId,
line_items : [{ price: priceId, quantity: 1 }],
allow_promotion_codes : true,
success_url : safeSuccess,
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',
authenticatePremiumUser,
async (req, res) => {
const base = PUBLIC_BASE || `https://${req.headers.host}`;
const { return_url } = req.query;
const safeReturn = return_url && isSafeRedirect(return_url)
? return_url
: `${base}/billing`;
const cid = await getOrCreateStripeCustomerId(req); // never null now
try {
const base = PUBLIC_BASE || `https://${req.headers.host}`;
const { return_url } = req.query;
const safeReturn = return_url && isSafeRedirect(return_url) ? return_url : `${base}/billing`;
const portal = await stripe.billingPortal.sessions.create({
customer : cid,
return_url
});
res.json({ url: portal.url });
const cid = await getOrCreateStripeCustomerId(req);
const portal = await stripe.billingPortal.sessions.create({
customer : cid,
return_url : safeReturn
});
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) => {
const { socCode } = req.params;
try {

View File

@ -1,35 +1,65 @@
// shared/auth/requireAuth.js
import jwt from 'jsonwebtoken';
import jwt from 'jsonwebtoken';
import pool from '../config/mysqlPool.js';
const { JWT_SECRET, TOKEN_MAX_AGE_MS } = process.env;
const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled
const {
JWT_SECRET,
TOKEN_MAX_AGE_MS,
SESSION_COOKIE_NAME
} = process.env;
const MAX_AGE = Number(TOKEN_MAX_AGE_MS || 0); // 0 = disabled
const COOKIE_NAME = SESSION_COOKIE_NAME || 'aptiva_session';
// Fallback cookie parser if cookie-parser middleware isn't present
function readSessionCookie(req) {
// Prefer cookie-parser, if installed
if (req.cookies && req.cookies[COOKIE_NAME]) return req.cookies[COOKIE_NAME];
// Manual parse from header
const raw = req.headers.cookie || '';
for (const part of raw.split(';')) {
const [k, ...rest] = part.trim().split('=');
if (k === COOKIE_NAME) return decodeURIComponent(rest.join('='));
}
return null;
}
export async function requireAuth(req, res, next) {
try {
// 1) Try Bearer (legacy) then cookie (current)
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' });
// 2) Verify JWT
let payload;
try { payload = jwt.verify(token, JWT_SECRET); }
catch { return res.status(401).json({ error: 'Invalid or expired token' }); }
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) {
return res.status(401).json({ error: 'Session expired. Please sign in again.' });
}
// Reject tokens issued before last password change
const [rows] = await (pool.raw || pool).query(
// 4) Invalidate tokens issued before last password change
const sql = pool.raw || pool;
const [rows] = await sql.query(
'SELECT password_changed_at FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId]
);
const changedAt = rows?.[0]?.password_changed_at || 0;
if (changedAt && iatMs < changedAt) {
const changedAtMs = rows?.[0]?.password_changed_at
? new Date(rows[0].password_changed_at).getTime()
: 0;
if (changedAtMs && iatMs < changedAtMs) {
return res.status(401).json({ error: 'Session invalidated. Please sign in again.' });
}

View File

@ -1,19 +1,28 @@
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> }`
* If no or bad token 401.
* Adds `req.user = { id }`
* Accepts either Bearer token or httpOnly cookie.
* 401 on missing; 401 again on invalid/expired.
*/
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" });
try {
const { id } = jwt.verify(token, JWT_SECRET);
req.user = { id }; // attach the id for downstream use
req.user = { id };
next();
} catch (err) {
} catch {
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,73 +1,121 @@
# ───────────────────────── config ─────────────────────────
#!/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}}"
case "$ENV" in dev|staging|prod) ;; *) echo "❌ Unknown ENV='$ENV'"; exit 1 ;; esac
case "$ENV" in dev|staging|prod) ;; # sanity guard
*) echo "❌ Unknown ENV='$ENV'"; exit 1 ;;
esac
PROJECT="aptivaai-${ENV}" # adjust if prod lives elsewhere
PROJECT="aptivaai-${ENV}"
REG="us-central1-docker.pkg.dev/${PROJECT}/aptiva-repo"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ROOT}/.env"
echo "🔧 Deploying environment: $ENV (GCP: $PROJECT)"
SECRETS=(
ENV_NAME PROJECT CORS_ALLOWED_ORIGINS
SERVER1_PORT SERVER2_PORT SERVER3_PORT
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_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD \
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA \
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE \
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID \
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_HOST DB_NAME DB_PORT DB_USER DB_PASSWORD
DB_SSL_CERT DB_SSL_KEY DB_SSL_CA
SUPPORT_SENDGRID_API_KEY EMAIL_INDEX_SECRET APTIVA_API_BASE
TWILIO_ACCOUNT_SID TWILIO_AUTH_TOKEN TWILIO_MESSAGING_SERVICE_SID
GOOGLE_MAPS_API_KEY
KMS_KEY_NAME DEK_PATH
)
cd "$ROOT"
echo "🛠 Building frontend bundle"
npm ci --silent
npm run build
# ───────────────────── build & push images ─────────────────────
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) ───────
# ───────────── pull runtime secrets (BEFORE build) ─────────────
echo "🔐 Pulling secrets from Secret Manager"
for S in "${SECRETS[@]}"; do
export "$S"="$(gcloud secrets versions access latest \
--secret="${S}_${ENV}" --project="$PROJECT")"
export "$S"="$(gcloud secrets versions access latest --secret="${S}_${ENV}" --project="$PROJECT")"
done
export FROM_SECRETS_MANAGER=true
# ───────────────────── compose up ───────────────────────────────
preserve=IMG_TAG,FROM_SECRETS_MANAGER,REACT_APP_API_URL,$(IFS=,; echo "${SECRETS[*]}")
# React needs the prefixed var at BUILD time
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)"
sudo --preserve-env="$preserve" docker compose up -d --force-recreate \
2> >(grep -v 'WARN \[0000\]')
2> >(grep -v 'WARN \[0000\]')
echo "✅ Deployment finished"
echo "✅ Deployment finished"

View File

@ -3,8 +3,6 @@
# Every secret is exported from fetchsecrets.sh and injected at deploy time.
# ---------------------------------------------------------------------------
x-env: &with-env
env_file:
- .env # committed, nonsecret
restart: unless-stopped
services:
@ -79,6 +77,7 @@ services:
PROJECT: ${PROJECT}
KMS_KEY_NAME: ${KMS_KEY_NAME}
DEK_PATH: ${DEK_PATH}
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
ONET_USERNAME: ${ONET_USERNAME}
ONET_PASSWORD: ${ONET_PASSWORD}
JWT_SECRET: ${JWT_SECRET}
@ -169,6 +168,8 @@ services:
command: ["nginx", "-g", "daemon off;"]
depends_on: [server1, server2, server3]
networks: [default, aptiva-shared]
environment:
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
ports: ["80:80", "443:443"]
volumes:
- ./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)
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/projections/ { 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/schools { 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",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cra-template": "1.2.0",
"docx": "^9.5.0",
@ -7328,6 +7329,28 @@
"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": {
"version": "1.0.6",
"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",
"classnames": "^2.5.1",
"clsx": "^2.1.1",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"cra-template": "1.2.0",
"docx": "^9.5.0",

View File

@ -41,8 +41,9 @@ import BillingResult from './components/BillingResult.js';
import SupportModal from './components/SupportModal.js';
import ForgotPassword from './components/ForgotPassword.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() {
const location = useLocation();
useEffect(() => {
try {
localStorage.removeItem('token');
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
clearToken();
try { localStorage.removeItem('id'); } catch {}
}, [location.pathname]);
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
===================== */
@ -172,57 +217,6 @@ const showPremiumCTA = !premiumPaths.some(p =>
setUserEmail(user?.email || '');
}, [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
@ -239,36 +233,47 @@ const showPremiumCTA = !premiumPaths.some(p =>
const confirmLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('id');
localStorage.removeItem('careerSuggestionsCache');
localStorage.removeItem('lastSelectedCareerProfileId');
localStorage.removeItem('selectedCareer');
localStorage.removeItem('aiClickCount');
localStorage.removeItem('aiClickDate');
localStorage.removeItem('aiRecommendations');
localStorage.removeItem('premiumOnboardingState'); // ← NEW
localStorage.removeItem('financialProfile'); // ← if you cache it
const confirmLogout = async () => {
// 1) Ask the server to clear the session cookie
try {
// If you created /logout (no /api prefix):
await api.post('/logout'); // axios client is withCredentials: true
// If your route is /api/signout instead, use:
// await api.post('/api/signout');
} catch (e) {
console.warn('Server logout failed (continuing client-side):', e?.message || e);
}
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);
setIsAuthenticated(false);
setUser(null);
setShowLogoutWarning(false);
// Reset auth
setIsAuthenticated(false);
setUser(null);
setShowLogoutWarning(false);
navigate('/signin');
// 4) Back to sign-in
navigate('/signin', { replace: true });
};
const cancelLogout = () => {
setShowLogoutWarning(false);
};
const cancelLogout = () => {
setShowLogoutWarning(false);
};
// ====================================
// 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
import { getToken } from './authMemory.js';
// src/auth/apiFetch.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 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}`);
return fetch(input, { ...init, headers });
}
return fetch(input, {
...init,
headers,
credentials: 'include' // ← send cookie
});
}

View File

@ -1,14 +1,24 @@
// authMemory.js
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 || '';
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() {
if (!accessToken) return '';
if (expiresAt && Date.now() > expiresAt) return '';
if (expiresAt && Date.now() > expiresAt) { clearToken(); return ''; }
return accessToken;
}
}

View File

@ -1,7 +1,8 @@
import { useEffect, useState, useContext } from 'react';
import { useLocation, Link } from 'react-router-dom';
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() {
const { setUser } = useContext(ProfileCtx) || {};
@ -11,14 +12,22 @@ export default function BillingResult() {
/*
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(() => {
const token = localStorage.getItem('token') || '';
fetch('/api/user-profile', { headers: { Authorization: `Bearer ${token}` } })
.then(r => r.ok ? r.json() : null)
.then(profile => { if (profile && setUser) setUser(profile); })
.finally(() => setLoading(false));
let cancelled = false;
(async () => {
try {
const { data } = await api.get('/api/user-profile');
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]);
/*

View File

@ -119,6 +119,7 @@ export default function CareerCoach({
const [showGoals , setShowGoals ] = useState(false);
const [draftGoals, setDraftGoals] = useState(scenarioRow?.career_goals || "");
const [saving , setSaving ] = useState(false);
const [threadId, setThreadId] = useState(null);
/* -------------- scroll --------------- */
useEffect(() => {
@ -135,6 +136,31 @@ useEffect(() => {
localStorage.setItem('coachChat:'+careerProfileId, JSON.stringify(messages.slice(-20)));
}, [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 ---------------- */
useEffect(() => {
if (!scenarioRow) return;
@ -206,50 +232,25 @@ I'm here to support you with personalized coaching. What would you like to focus
/* ------------ shared AI caller ------------- */
async function callAi(updatedHistory, opts = {}) {
setLoading(true);
try {
const payload = {
userProfile,
financialProfile,
scenarioRow,
collegeProfile,
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 replyRaw = data?.reply ?? ""; // always a string
const riskData = data?.aiRisk;
const createdMilestones = data?.createdMilestones ?? [];
// guard empty or non-string → generic apology
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 {
setLoading(false);
}
setLoading(true);
try {
const context = { userProfile, financialProfile, scenarioRow, collegeProfile };
const r = await authFetch(`/api/premium/coach/chat/threads/${threadId}/messages`, {
method:'POST',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({ content: updatedHistory.at(-1)?.content || '', context })
});
const data = await r.json();
const reply = (data?.reply || '').trim() || 'Sorry, something went wrong.';
setMessages(prev => [...prev, { role:'assistant', content: reply }]);
} catch (e) {
console.error(e);
setMessages(prev => [...prev, { role:'assistant', content:'Sorry, something went wrong.' }]);
} finally {
setLoading(false);
}
}
/* ------------ normal send ------------- */
function handleSubmit(e) {

View File

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

View File

@ -1,5 +1,4 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { AlertTriangle } from 'lucide-react';
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 (
<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">
<p className="text-lg text-gray-700">Loading career details...</p>
</div>
@ -61,7 +60,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
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">
@ -88,11 +87,8 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</h2>
{/* 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">
<strong>AI Risk Level:</strong> {aiRisk.riskLevel}
<br />
@ -100,7 +96,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</div>
)}
{!loadingRisk && !aiRisk && (
{!aiRisk && (
<p className="text-sm text-gray-500 mt-1">No AI risk data available</p>
)}
</div>
@ -181,10 +177,10 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
<tr key={i}>
<td className="px-3 py-2 border-b">{row.percentile}</td>
<td className="px-3 py-2 border-b">
${row.regionalSalary.toLocaleString()}
{Number.isFinite(row.regionalSalary) ? `$${fmt(row.regionalSalary)}` : '—'}
</td>
<td className="px-3 py-2 border-b">
${row.nationalSalary.toLocaleString()}
{Number.isFinite(row.nationalSalary) ? `$${fmt(row.nationalSalary)}` : '—'}
</td>
</tr>
))}
@ -219,12 +215,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</td>
{careerDetails.economicProjections.state && (
<td className="px-3 py-2 border-b">
{careerDetails.economicProjections.state.base.toLocaleString()}
{fmt(careerDetails.economicProjections.state.base)}
</td>
)}
{careerDetails.economicProjections.national && (
<td className="px-3 py-2 border-b">
{careerDetails.economicProjections.national.base.toLocaleString()}
{fmt(careerDetails.economicProjections.national.base)}
</td>
)}
</tr>
@ -234,12 +230,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</td>
{careerDetails.economicProjections.state && (
<td className="px-3 py-2 border-b">
{careerDetails.economicProjections.state.projection.toLocaleString()}
{fmt(careerDetails.economicProjections.state.projection)}
</td>
)}
{careerDetails.economicProjections.national && (
<td className="px-3 py-2 border-b">
{careerDetails.economicProjections.national.projection.toLocaleString()}
{fmt(careerDetails.economicProjections.national.projection)}
</td>
)}
</tr>
@ -247,12 +243,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
<td className="px-3 py-2 border-b font-semibold">Growth%</td>
{careerDetails.economicProjections.state && (
<td className="px-3 py-2 border-b">
{careerDetails.economicProjections.state.percentChange}%
{fmt(careerDetails.economicProjections.state.percentChange)}%
</td>
)}
{careerDetails.economicProjections.national && (
<td className="px-3 py-2 border-b">
{careerDetails.economicProjections.national.percentChange}%
{fmt(careerDetails.economicProjections.national.percentChange)}%
</td>
)}
</tr>
@ -262,12 +258,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
</td>
{careerDetails.economicProjections.state && (
<td className="px-3 py-2 border-b">
{careerDetails.economicProjections.state.annualOpenings.toLocaleString()}
{fmt(careerDetails.economicProjections.state.annualOpenings)}
</td>
)}
{careerDetails.economicProjections.national && (
<td className="px-3 py-2 border-b">
{careerDetails.economicProjections.national.annualOpenings.toLocaleString()}
{fmt(careerDetails.economicProjections.national.annualOpenings)}
</td>
)}
</tr>

View File

@ -1,29 +1,47 @@
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() {
const [rows, setRows] = useState([]);
const nav = useNavigate();
const token = localStorage.getItem('token');
const nav = useNavigate();
useEffect(() => {
fetch('/api/premium/career-profile/all', {
headers: { Authorization: `Bearer ${token}` }
})
.then(r => r.json())
.then(d => setRows(d.careerProfiles || []));
}, [token]);
(async () => {
try {
const r = await apiFetch('/api/premium/career-profile/all');
if (!r.ok) {
// apiFetch already fires session-expired on 401/403, just bail
return;
}
const d = await r.json();
setRows(d.careerProfiles || []);
} catch (e) {
console.error('Failed to load career profiles:', e);
}
})();
}, []);
async function remove(id) {
if (!window.confirm('Delete this career profile?')) return;
await fetch(`/api/premium/career-profile/${id}`, {
method : 'DELETE',
headers: { Authorization: `Bearer ${token}` }
});
setRows(rows.filter(r => r.id !== id));
}
return (
try {
const r = await apiFetch(`/api/premium/career-profile/${id}`, {
method: 'DELETE',
});
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 (
<div className="max-w-4xl mx-auto space-y-4">
<h2 className="text-2xl font-semibold">Career Profiles</h2>

View File

@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router-dom';
import { Line, Bar } from 'react-chartjs-2';
import { format } from 'date-fns'; // ⬅ install if not already
import zoomPlugin from 'chartjs-plugin-zoom';
import axios from 'axios';
import api from '../auth/apiClient.js';
import {
Chart as ChartJS,
LineElement,
@ -23,7 +23,7 @@ import MilestoneEditModal from './MilestoneEditModal.js';
import buildChartMarkers from '../utils/buildChartMarkers.js';
import getMissingFields, { MISSING_LABELS } from '../utils/getMissingFields.js';
import 'chartjs-adapter-date-fns';
import authFetch from '../utils/authFetch.js';
import apiFetch from '../auth/apiFetch.js';
import { simulateFinancialProjection } from '../utils/FinancialProjectionService.js';
import parseFloatOrZero from '../utils/ParseFloatorZero.js';
import { getFullStateName } from '../utils/stateUtils.js';
@ -41,6 +41,7 @@ import differenceInMonths from 'date-fns/differenceInMonths';
import "../styles/legacy/MilestoneTimeline.legacy.css";
const authFetch = apiFetch;
// --------------
// Register ChartJS Plugins
// --------------
@ -824,14 +825,14 @@ async function fetchAiRisk(socCode, careerName, description, tasks) {
try {
// 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, ... }
} catch (err) {
// 2) If 404 => call server3
if (err.response && err.response.status === 404) {
try {
// 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,
careerName,
jobDescription: description,
@ -865,7 +866,7 @@ try {
}
// 3) Store in server2
await axios.post('/api/ai-risk', storePayload);
await api.post('/api/ai-risk', storePayload);
// Construct final object for usage here
aiRisk = {
@ -902,7 +903,10 @@ useEffect(() => {
(async () => {
try {
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) {
setSalaryData(await res.json());
@ -1117,8 +1121,6 @@ if (allMilestones.length) {
randomRangeMax
};
console.log('Merged profile to simulate =>', mergedProfile);
const { projectionData: pData, loanPaidOffMonth } =
simulateFinancialProjection(mergedProfile);

View File

@ -7,6 +7,20 @@ import { Input } from './ui/input.js';
import { MessageCircle } from 'lucide-react';
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
support-bot lives in this file (streamed from /api/chat/free)
@ -27,6 +41,7 @@ export default function ChatDrawer({
/* ─────────────────────────── internal / fallback state ───────── */
const [openLocal, setOpenLocal] = useState(false);
const [paneLocal, setPaneLocal] = useState('support');
const [supportThreadId, setSupportThreadId] = useState(null);
/* prefer the controlled props when supplied */
const open = controlledOpen ?? openLocal;
@ -45,6 +60,17 @@ export default function ChatDrawer({
(listRef.current.scrollTop = listRef.current.scrollHeight);
}, [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 */
const pushAssistant = (chunk) =>
setMessages((prev) => {
@ -69,62 +95,45 @@ export default function ChatDrawer({
/* ───────────────────────── send support-bot prompt ───────────── */
async function sendPrompt() {
const text = prompt.trim();
if (!text) return;
const text = prompt.trim();
if (!text || !supportThreadId) return;
setMessages((m) => [...m, { role: 'user', content: text }]);
setPrompt('');
setMessages(m => [...m, { role:'user', content:text }]);
setPrompt('');
const body = JSON.stringify({
prompt: text,
pageContext,
chatHistory: messages,
snapshot,
try {
const resp = await fetch(`/api/support/chat/threads/${supportThreadId}/stream`, {
method: 'POST',
credentials: 'include',
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}`);
try {
const token = localStorage.getItem('token') || '';
const headers = {
'Content-Type': 'application/json',
Accept: 'text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
const resp = await fetch('/api/chat/free', {
method: 'POST',
headers,
body,
});
if (!resp.ok || !resp.body) throw new Error(`HTTP ${resp.status}`);
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
buf += decoder.decode(value, { stream:true });
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
/* eslint-disable no-await-in-loop */
const { value, done } = await reader.read();
/* eslint-enable no-await-in-loop */
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) pushAssistant(line + '\n');
}
let nl;
while ((nl = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (line) pushAssistant(line + '\n');
}
if (buf.trim()) pushAssistant(buf);
} catch (err) {
console.error('[ChatDrawer] stream error', err);
pushAssistant(
'Sorry — something went wrong. Please try again later.'
);
}
if (buf.trim()) pushAssistant(buf);
} catch (e) {
console.error('[Support stream]', e);
pushAssistant('Sorry — something went wrong. Please try again later.');
}
}
const handleKeyDown = (e) => {
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 { useNavigate, useParams } from 'react-router-dom';
import authFetch from '../utils/authFetch.js';
import apiFetch from '../auth/apiFetch.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
* mirrors the logic you use in OnboardingContainer
@ -45,7 +45,6 @@ const toMySqlDate = iso => {
export default function CollegeProfileForm() {
const { careerId, id } = useParams(); // id optional
const nav = useNavigate();
const token = localStorage.getItem('token');
const [cipRows, setCipRows] = useState([]);
const [schoolSug, setSchoolSug] = useState([]);
const [progSug, setProgSug] = useState([]);
@ -126,13 +125,12 @@ const onProgramInput = (e) => {
useEffect(() => {
if (id && id !== 'new') {
fetch(`/api/premium/college-profile?careerProfileId=${careerId}`, {
headers: { Authorization: `Bearer ${token}` }
})
.then(r => r.json())
.then(setForm);
(async () => {
const r = await authFetch(`/api/premium/college-profile?careerProfileId=${careerId}`);
if (r.ok) setForm(await r.json());
})();
}
}, [careerId, id, token]);
}, [careerId, id]);
async function handleSave(){
try{
@ -152,7 +150,7 @@ const onProgramInput = (e) => {
/* LOAD iPEDS ----------------------------- */
useEffect(() => {
fetch('/ic2023_ay.csv')
fetch('/ic2023_ay.csv', { credentials: 'omit' })
.then(r => r.text())
.then(text => {
const rows = text.split('\n').map(l => l.split(','));
@ -165,7 +163,7 @@ useEffect(() => {
.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(
t.split('\n').map(l=>{try{return JSON.parse(l)}catch{ return null }})
.filter(Boolean)
@ -235,9 +233,11 @@ useEffect(() => {
]);
const handleManualTuitionChange = e => setManualTuition(e.target.value);
const chosenTuition = manualTuition.trim() === ''
? autoTuition
: parseFloat(manualTuition);
const chosenTuition = (() => {
if (manualTuition.trim() === '') return autoTuition;
const n = parseFloat(manualTuition);
return Number.isFinite(n) ? n : autoTuition;
})();
/*
Autocalculate PROGRAM LENGTH when the user hasnt typed in

View File

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

View File

@ -4,6 +4,35 @@ import CareerSearch from './CareerSearch.js';
import { ONET_DEFINITIONS } from './definitions.js';
import { fetchSchools, clientGeocodeZip, haversineDistance } from '../utils/apiUtils.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
function combineIMandLV(rows) {
@ -90,6 +119,7 @@ function EducationalProgramsPage() {
const [showSearch, setShowSearch] = useState(true);
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?'
);
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 } });
}
};
@ -235,7 +261,7 @@ useEffect(() => {
if (combined.length === 0) {
// We found ZERO local KSA records for this socCode => fallback
fetchAiKsaFallback(socCode, careerTitle);
fetchKsaFallback(socCode, careerTitle);
} else {
// We found local KSA data => just use it
setKsaForCareer(combined);
@ -243,19 +269,11 @@ useEffect(() => {
}, [socCode, allKsaData, careerTitle]);
// Load user profile
// Load user profile (cookie-based auth via api client)
useEffect(() => {
async function loadUserProfile() {
try {
const token = localStorage.getItem('token');
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();
const { data } = await api.get('/api/user-profile');
setUserZip(data.zipcode || '');
setUserState(data.state || '');
} catch (err) {
@ -582,50 +600,49 @@ const topSchools = filteredAndSortedSchools.slice(0, TOP_N).map(s => ({
);
}
async function fetchAiKsaFallback(socCode, careerTitle) {
// Optionally show a “loading” indicator
// No local KSA records for this SOC => ask server3 to resolve (local/DB/GPT)
async function fetchKsaFallback(socCode, careerTitle) {
setLoadingKsa(true);
setKsaError(null);
try {
const token = localStorage.getItem('token');
if (!token) {
throw new Error('No auth token found; cannot fetch AI-based KSAs.');
}
// Call the new endpoint in server3.js
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;
// Ask server3. It will:
// 1) Serve local ksa_data.json if present for this SOC
// 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}`, {
params: { careerTitle: careerTitle || '' }
});
const combined = combineIMandLV(finalKsa);
setKsaForCareer(combined);
} catch (err) {
console.error('Error fetching AI-based KSAs:', err);
setKsaError('Could not load AI-based KSAs. Please try again later.');
setKsaForCareer([]);
} finally {
setLoadingKsa(false);
}
}
// 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);
} catch (err) {
console.error('Error fetching KSAs:', err);
setKsaError('Could not load KSAs. Please try again later.');
setKsaForCareer([]);
} finally {
setLoadingKsa(false);
}
}
return (
<div className="p-4">

View File

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

View File

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

View File

@ -1,258 +1,208 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
// src/pages/premium/OnboardingContainer.js
import React, { useState, useEffect, useRef } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import PremiumWelcome from './PremiumWelcome.js';
import CareerOnboarding from './CareerOnboarding.js';
import FinancialOnboarding from './FinancialOnboarding.js';
import CollegeOnboarding from './CollegeOnboarding.js';
import ReviewPage from './ReviewPage.js';
import { loadDraft, saveDraft, clearDraft } from '../../utils/onboardingDraftApi.js';
import authFetch from '../../utils/authFetch.js';
const OnboardingContainer = () => {
console.log('OnboardingContainer MOUNT');
const POINTER_KEY = 'premiumOnboardingPointer';
export default function OnboardingContainer() {
const navigate = useNavigate();
const location = useLocation();
// 1. Local state for multi-step onboarding
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 [financialData, setFinancialData] = useState({});
const [collegeData, setCollegeData] = useState({});
const [lastSelectedCareerProfileId, setLastSelectedCareerProfileId] = useState();
const skipFin = careerData.skipFinancialStep;
const [loaded, setLoaded] = useState(false);
useEffect(() => {
// 1) Load premiumOnboardingState
const stored = localStorage.getItem('premiumOnboardingState');
let localCareerData = {};
let localFinancialData = {};
let localCollegeData = {};
let localStep = 0;
// pointer (safe to store)
const ptrRef = useRef({ id: null, step: 0, skipFin: false, selectedCareer: null });
if (stored) {
try {
const parsed = JSON.parse(stored);
if (parsed.step !== undefined) localStep = parsed.step;
if (parsed.careerData) localCareerData = parsed.careerData;
if (parsed.financialData) localFinancialData = parsed.financialData;
if (parsed.collegeData) localCollegeData = parsed.collegeData;
} catch (err) {
console.warn('Failed to parse premiumOnboardingState:', err);
}
}
// 2) If there's a "lastSelectedCareerProfileId", override or set the career_profile_id
const existingId = localStorage.getItem('lastSelectedCareerProfileId');
if (existingId) {
// Only override if there's no existing ID in localCareerData
// or if you specifically want to *always* use the lastSelected ID.
localCareerData.career_profile_id = existingId;
}
// 3) Finally set states once
setStep(localStep);
setCareerData(localCareerData);
setFinancialData(localFinancialData);
setCollegeData(localCollegeData);
}, []);
// 3. Whenever any key pieces of state change, save to localStorage
// ---- 1) one-time load/migrate & hydrate -----------------------
useEffect(() => {
const stateToStore = {
step,
careerData,
financialData,
collegeData
};
localStorage.setItem('premiumOnboardingState', JSON.stringify(stateToStore));
}, [step, careerData, financialData, collegeData]);
// Move user to next or previous step
const nextStep = () => setStep(prev => prev + 1);
const prevStep = () => setStep(prev => prev - 1);
// Helper: parse float or return null
function parseFloatOrNull(value) {
if (value == null || value === '') return null;
const parsed = parseFloat(value);
return isNaN(parsed) ? null : parsed;
}
function finishImmediately() {
// 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 {
// -- 1) Upsert scenario (career-profile) --
// 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', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(scenarioPayload),
});
if (!scenarioRes.ok) {
throw new Error('Failed to save (or update) career profile');
}
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) {
throw new Error('Failed to save financial profile');
}
// 3) If user is in or planning college => upsert college-profile
if (
careerData.college_enrollment_status === 'currently_enrolled' ||
careerData.college_enrollment_status === 'prospective_student'
) {
// Build an object that has all the correct property names
const mergedCollegeData = {
...collegeData,
career_profile_id: finalCareerProfileId,
college_enrollment_status: careerData.college_enrollment_status,
is_in_state: !!collegeData.is_in_state,
is_in_district: !!collegeData.is_in_district,
is_online: !!collegeData.is_online, // ensure it matches backend naming
loan_deferral_until_graduation: !!collegeData.loan_deferral_until_graduation,
};
// Convert numeric fields
const numericFields = [
'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'
];
numericFields.forEach(field => {
const val = parseFloatOrNull(mergedCollegeData[field]);
// If you want them to be 0 when blank, do:
mergedCollegeData[field] = val ?? 0;
});
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');
(async () => {
// A) migrate any old local blob (once), then delete it
const oldRaw = localStorage.getItem('premiumOnboardingState'); // legacy
if (oldRaw) {
try {
const legacy = JSON.parse(oldRaw);
const draft = await saveDraft({
id: null,
step: legacy.step ?? 0,
careerData: legacy.careerData || {},
financialData: legacy.financialData || {},
collegeData: legacy.collegeData || {}
});
ptrRef.current = {
id: draft.id,
step: draft.step,
skipFin: !!legacy?.careerData?.skipFinancialStep,
selectedCareer: JSON.parse(localStorage.getItem('selectedCareer') || 'null')
};
localStorage.removeItem('premiumOnboardingState'); // nuke
localStorage.setItem(POINTER_KEY, JSON.stringify(ptrRef.current));
} catch (e) {
console.warn('Legacy migration failed; wiping local blob.', e);
localStorage.removeItem('premiumOnboardingState');
}
}
} else {
console.log(
'Skipping college-profile upsert; user not in or planning college.'
);
// B) load pointer
try {
const pointer = JSON.parse(localStorage.getItem(POINTER_KEY) || 'null') || {};
ptrRef.current = {
id: pointer.id || null,
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 || ''
}));
}
}
// D) pick up any navigation state (e.g., selectedSchool)
const navSchool = location.state?.selectedSchool;
if (navSchool) {
setCollegeData(cd => ({ ...cd, selected_school: navSchool.INSTNM || navSchool }));
}
setLoaded(true);
})();
}, [location.state]);
// ---- 2) debounced autosave to server + pointer update ----------
useEffect(() => {
if (!loaded) return;
const t = setTimeout(async () => {
// persist server draft (all sensitive data)
const resp = await saveDraft({
id: ptrRef.current.id,
step,
careerData,
financialData,
collegeData
});
// update pointer (safe)
const pointer = {
id: resp.id,
step,
skipFin: !!careerData.skipFinancialStep,
selectedCareer: (careerData.career_name || careerData.soc_code)
? { title: careerData.career_name, soc_code: careerData.soc_code }
: JSON.parse(localStorage.getItem('selectedCareer') || 'null')
};
ptrRef.current = pointer;
localStorage.setItem(POINTER_KEY, JSON.stringify(pointer));
}, 400); // debounce
return () => clearTimeout(t);
}, [loaded, step, careerData, financialData, collegeData]);
// ---- nav helpers ------------------------------------------------
const nextStep = () => setStep((s) => s + 1);
const prevStep = () => setStep((s) => Math.max(0, s - 1));
// Steps: Welcome, Career, (Financial?), College, Review => 4 or 5 total
const finishImmediately = () => setStep(skipFin ? 3 : 4);
// ---- final submit (unchanged + cleanup) -------------------------
async function handleFinalSubmit() {
try {
// 1) scenario upsert
const scenarioPayload = { ...careerData, id: careerData.career_profile_id || undefined };
const scenarioRes = await authFetch('/api/premium/career-profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
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');
// 2) financial profile
const finRes = await authFetch('/api/premium/financial-profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(financialData)
});
if (!finRes || !finRes.ok) throw new Error('Failed to save financial profile');
// 3) college profile (conditional)
if (['currently_enrolled','prospective_student'].includes(careerData.college_enrollment_status)) {
const merged = {
...collegeData,
career_profile_id: finalId,
college_enrollment_status: careerData.college_enrollment_status,
is_in_state: !!collegeData.is_in_state,
is_in_district: !!collegeData.is_in_district,
is_online: !!collegeData.is_online,
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; });
const colRes = await authFetch('/api/premium/college-profile', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(merged)
});
if (!colRes || !colRes.ok) throw new Error('Failed to save college profile');
}
// 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');
// 🔐 cleanup: remove server draft + pointer
await clearDraft();
localStorage.removeItem(POINTER_KEY);
navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } });
} catch (err) {
console.error('Error in final submit =>', err);
alert(err.message || 'Failed to finalize onboarding.');
}
const picked = { code: careerData.soc_code, title: careerData.career_name }
// 🚀 right before you navigate away from the review page
sessionStorage.setItem('skipMissingModalFor', String(finalCareerProfileId));
localStorage.setItem('selectedCareer', JSON.stringify(picked));
localStorage.removeItem('lastSelectedCareerProfileId');
navigate(`/career-roadmap/${finalCareerProfileId}`, {
state: { fromOnboarding: true,
selectedCareer : picked
}
});
} catch (err) {
console.error('Error in final submit =>', err);
alert(err.message || 'Failed to finalize onboarding.');
}
};
// 5. Array of steps
const onboardingSteps = [
const skipFin = !!careerData.skipFinancialStep;
const steps = [
<PremiumWelcome nextStep={nextStep} />,
<CareerOnboarding
nextStep={nextStep}
finishNow={finishImmediately}
data={careerData}
setData={setCareerData}
/>,
/* 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}
/>,
<CareerOnboarding nextStep={nextStep} finishNow={finishImmediately} data={careerData} setData={setCareerData} />,
...(!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>;
};
export default OnboardingContainer;
const safeIndex = Math.min(step, steps.length - 1);
return <div>{loaded ? steps[safeIndex] : null}</div>;
}

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import api from '../auth/apiClient.js';
function ResumeRewrite() {
const [resumeFile, setResumeFile] = useState(null);
@ -9,20 +9,44 @@ function ResumeRewrite() {
const [error, setError] = useState('');
const [remainingOptimizations, setRemainingOptimizations] = 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) => {
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 () => {
try {
const token = localStorage.getItem('token');
const res = await axios.get('/api/premium/resume/remaining', {
headers: { Authorization: `Bearer ${token}` },
});
const res = await api.get('/api/premium/resume/remaining', { withCredentials: true });
setRemainingOptimizations(res.data.remainingOptimizations);
setResetDate(new Date(res.data.resetDate).toLocaleDateString());
setResetDate(res.data.resetDate ? new Date(res.data.resetDate).toLocaleDateString() : null);
} catch (err) {
console.error('Error fetching optimizations:', err);
setError('Could not fetch optimization limits.');
@ -35,25 +59,24 @@ function ResumeRewrite() {
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setOptimizedResume('');
if (!resumeFile || !jobTitle.trim() || !jobDescription.trim()) {
setError('Please fill in all fields.');
return;
}
setLoading(true); // ACTIVATE loading
setLoading(true);
try {
const token = localStorage.getItem('token');
const formData = new FormData();
formData.append('resumeFile', resumeFile);
formData.append('jobTitle', jobTitle);
formData.append('jobDescription', jobDescription);
formData.append('jobTitle', jobTitle.trim());
formData.append('jobDescription', jobDescription.trim());
const res = await axios.post('/api/premium/resume/optimize', formData, {
headers: {
'Content-Type': 'multipart/form-data',
Authorization: `Bearer ${token}`,
},
// Let axios/browser set multipart boundary automatically; just include credentials.
const res = await api.post('/api/premium/resume/optimize', formData, {
withCredentials: true,
});
setOptimizedResume(res.data.optimizedResume || '');
@ -63,7 +86,7 @@ function ResumeRewrite() {
console.error('Resume optimization error:', err);
setError(err.response?.data?.error || 'Failed to optimize resume.');
} finally {
setLoading(false); // DEACTIVATE loading
setLoading(false);
}
};
@ -80,15 +103,26 @@ function ResumeRewrite() {
<form onSubmit={handleSubmit} className="space-y-6">
<div>
<label className="block font-medium text-gray-700 mb-1">Upload Resume (PDF or DOCX):</label>
<input type="file" accept=".pdf,.docx" onChange={handleFileChange}
<label className="block font-medium text-gray-700 mb-1">
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"
/>
</div>
<div>
<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"
placeholder="e.g., Software Engineer"
/>
@ -96,8 +130,14 @@ function ResumeRewrite() {
<div>
<label className="block font-medium text-gray-700 mb-1">Job Description:</label>
<textarea value={jobDescription} onChange={(e) => setJobDescription(e.target.value)}
rows={4} className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200"
<textarea
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..."
/>
</div>
@ -125,7 +165,9 @@ function ResumeRewrite() {
{optimizedResume && (
<div className="mt-8">
<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>

View File

@ -104,11 +104,35 @@ export default function RetirementChatBar({
const [forceCtx, setForceCtx] = useState(false);
const [scenarios, setScenarios] = useState([]);
const [currentScenario, setCurrentScenario] = useState(scenario);
const [threadId, setThreadId] = useState(null);
const bottomRef = useRef(null);
/* wipe chat on scenario change */
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 */
useEffect(() => {
(async () => {
@ -156,15 +180,14 @@ async function sendPrompt() {
});
/* ③ POST to the retirement endpoint */
const res = await authFetch('/api/premium/retirement/aichat', {
method : 'POST',
headers: { 'Content-Type': 'application/json' },
body : JSON.stringify({
prompt,
scenario_id : currentScenario?.id, // ← keep it minimal
chatHistory : messagesToSend // ← backend needs this to find userMsg
})
});
const res = await authFetch(`/api/premium/retire/chat/threads/${threadId}/messages`, {
method:'POST',
headers:{ 'Content-Type':'application/json' },
body: JSON.stringify({
content: prompt,
context: { scenario_id: currentScenario.id } // minimal — your backend uses it
})
});
const data = await res.json();
const assistantReply = data.reply || '(no response)';

View File

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

View File

@ -4,59 +4,81 @@ import ChangePasswordForm from './ChangePasswordForm.js';
function UserProfile() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [zipCode, setZipCode] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [zipCode, setZipCode] = useState('');
const [selectedState, setSelectedState] = useState('');
const [areas, setAreas] = useState([]);
const [selectedArea, setSelectedArea] = useState('');
const [careerSituation, setCareerSituation] = useState('');
const [loadingAreas, setLoadingAreas] = useState(false);
const [isPremiumUser, setIsPremiumUser] = useState(false);
const [phoneE164, setPhoneE164] = useState('');
const [smsOptIn, setSmsOptIn] = useState(false);
const [smsOptIn, setSmsOptIn] = 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();
// Helper to do authorized fetch
// Cookie-auth helper: include credentials; if unauthorized, bounce to sign-in
const authFetch = async (url, options = {}) => {
const token = localStorage.getItem('token');
if (!token) {
navigate('/signin');
return null;
}
const res = await fetch(url, {
credentials: 'include',
...options,
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
...options.headers,
Accept: 'application/json',
...(options.headers || {}),
},
});
if ([401, 403].includes(res.status)) {
console.warn('Token invalid or expired. Redirecting to Sign In.');
navigate('/signin');
if (res.status === 401 || res.status === 419) {
navigate('/signin?session=expired');
return null;
}
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(() => {
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 {
const token = localStorage.getItem('token');
if (!token) return;
const res = await authFetch('/api/user-profile', {
method: 'GET',
});
const res = await authFetch('/api/user-profile', { method: 'GET' });
if (!res || !res.ok) return;
const data = await res.json();
setFirstName(data.firstname || '');
@ -69,20 +91,13 @@ function UserProfile() {
setPhoneE164(data.phone_e164 || '');
setSmsOptIn(!!data.sms_opt_in);
if (data.is_premium === 1) {
setIsPremiumUser(true);
}
// If we have a state, load its areas
if (data.state) {
setLoadingAreas(true);
try {
const areaRes = await authFetch(`/api/areas?state=${data.state}`);
if (!areaRes || !areaRes.ok) {
throw new Error('Failed to fetch areas');
}
const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(data.state)}`);
if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas');
const areaData = await areaRes.json();
setAreas(areaData.areas);
setAreas(areaData.areas || []);
} catch (areaErr) {
console.error('Error fetching areas:', areaErr);
setAreas([]);
@ -93,34 +108,18 @@ function UserProfile() {
} catch (error) {
console.error('Error loading user profile:', error);
}
};
fetchProfileAndAreas();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // only runs once
}, []);
// Whenever user changes "selectedState", re-fetch areas
// Refetch areas when state changes
useEffect(() => {
const fetchAreasByState = async () => {
if (!selectedState) {
setAreas([]);
return;
}
(async () => {
if (!selectedState) { setAreas([]); return; }
setLoadingAreas(true);
try {
const token = localStorage.getItem('token');
if (!token) return;
const areaRes = await fetch(`/api/areas?state=${selectedState}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!areaRes.ok) {
throw new Error('Failed to fetch areas');
}
const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(selectedState)}`);
if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas');
const areaData = await areaRes.json();
setAreas(areaData.areas || []);
} catch (error) {
@ -129,14 +128,12 @@ function UserProfile() {
} finally {
setLoadingAreas(false);
}
};
fetchAreasByState();
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedState]);
const handleFormSubmit = async (e) => {
e.preventDefault();
const profileData = {
firstName,
lastName,
@ -146,25 +143,22 @@ function UserProfile() {
area: selectedArea,
careerSituation,
phone_e164: phoneE164 || null,
sms_opt_in: !!smsOptIn
sms_opt_in: !!smsOptIn,
};
try {
const response = await authFetch('/api/user-profile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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');
} catch (error) {
console.error('Error saving profile:', error);
}
};
// FULL list of states for your dropdown
const states = [
{ name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' },
{ 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' },
];
// The updated career situations (same as in SignUp.js)
const careerSituations = [
{
id: 'planning',
title: 'Planning Your Career',
},
{
id: 'preparing',
title: 'Preparing for Your (Next) Career',
},
{
id: 'enhancing',
title: 'Enhancing Your Career',
},
{
id: 'retirement',
title: 'Retirement Planning',
},
{ id: 'planning', title: 'Planning Your Career' },
{ id: 'preparing', title: 'Preparing for Your (Next) Career' },
{ id: 'enhancing', title: 'Enhancing Your Career' },
{ id: 'retirement', title: 'Retirement Planning' },
];
return (
@ -213,9 +194,7 @@ function UserProfile() {
<form onSubmit={handleFormSubmit} className="space-y-4">
{/* First Name */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
First Name:
</label>
<label className="mb-1 block text-sm font-medium text-gray-700">First Name:</label>
<input
type="text"
value={firstName}
@ -227,9 +206,7 @@ function UserProfile() {
{/* Last Name */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Last Name:
</label>
<label className="mb-1 block text-sm font-medium text-gray-700">Last Name:</label>
<input
type="text"
value={lastName}
@ -241,9 +218,7 @@ function UserProfile() {
{/* Email */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Email:
</label>
<label className="mb-1 block text-sm font-medium text-gray-700">Email:</label>
<input
type="email"
value={email}
@ -253,11 +228,9 @@ function UserProfile() {
/>
</div>
{/* ZIP Code */}
{/* ZIP */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
ZIP Code:
</label>
<label className="mb-1 block text-sm font-medium text-gray-700">ZIP Code:</label>
<input
type="text"
value={zipCode}
@ -267,11 +240,9 @@ function UserProfile() {
/>
</div>
{/* State Dropdown */}
{/* State */}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
State:
</label>
<label className="mb-1 block text-sm font-medium text-gray-700">State:</label>
<select
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
@ -280,24 +251,17 @@ function UserProfile() {
>
<option value="">Select a State</option>
{states.map((s) => (
<option key={s.code} value={s.code}>
{s.name}
</option>
<option key={s.code} value={s.code}>{s.name}</option>
))}
</select>
</div>
{/* Loading indicator for areas */}
{loadingAreas && (
<p className="text-sm text-gray-500">Loading areas...</p>
)}
{/* Areas */}
{loadingAreas && <p className="text-sm text-gray-500">Loading areas...</p>}
{/* Areas Dropdown */}
{!loadingAreas && areas.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700">
Area:
</label>
<label className="mb-1 block text-sm font-medium text-gray-700">Area:</label>
<select
value={selectedArea}
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"
>
<option value="">Select an Area</option>
{areas.map((area, index) => (
<option key={index} value={area}>
{area}
</option>
{areas.map((area, idx) => (
<option key={idx} value={area}>{area}</option>
))}
</select>
</div>
)}
{/* Phone + SMS */}
<div className="mt-4">
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
<input
@ -345,30 +308,71 @@ function UserProfile() {
>
<option value="">Select One</option>
{careerSituations.map((cs) => (
<option key={cs.id} value={cs.id}>
{cs.title}
</option>
<option key={cs.id} value={cs.id}>{cs.title}</option>
))}
</select>
</div>
<div className="mt-8">
<button
type="button"
onClick={() => setShowChangePw(s => !s)}
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
>
{showChangePw ? 'Cancel password change' : 'Change password'}
</button>
{/* Password */}
<div className="mt-8">
<button
type="button"
onClick={() => setShowChangePw((s) => !s)}
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
>
{showChangePw ? 'Cancel password change' : 'Change password'}
</button>
{showChangePw && (
<div className="mt-4">
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
</div>
)}
</div>
{/* Form Buttons */}
{showChangePw && (
<div className="mt-4">
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
</div>
)}
</div>
{/* 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">
<button
type="submit"

View File

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

View File

@ -2,15 +2,17 @@ import axios from 'axios';
export async function clientGeocodeZip(zip) {
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);
if (resp.data.status === 'OK' && resp.data.results && resp.data.results.length > 0) {
const url =
`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 }
}
throw new Error('Geocoding failed.');
throw new Error(`Geocoding failed: ${resp.data.status || 'Unknown'}.`);
}
// utils/apiUtils.js
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;
export const setSessionExpiredCallback = (callback) => {
@ -5,26 +10,21 @@ export const setSessionExpiredCallback = (callback) => {
};
const authFetch = async (url, options = {}) => {
const token = localStorage.getItem('token');
if (!token) {
onSessionExpiredCallback?.();
return null;
}
const method = options.method?.toUpperCase() || 'GET';
const shouldIncludeContentType = ['POST', 'PUT', 'PATCH'].includes(method);
const method = (options.method || 'GET').toUpperCase();
const hasCTHeader = options.headers && Object.prototype.hasOwnProperty.call(options.headers, 'Content-Type');
const shouldIncludeContentType = ['POST','PUT','PATCH'].includes(method) && !hasCTHeader;
const res = await fetch(url, {
credentials: 'include', // <-- send httpOnly session cookie
...options,
headers: {
Authorization: `Bearer ${token}`,
...(shouldIncludeContentType && { 'Content-Type': 'application/json' }),
...options.headers,
...(shouldIncludeContentType ? { 'Content-Type': 'application/json' } : {}),
Accept: 'application/json',
...(options.headers || {}),
},
});
if ([401, 403].includes(res.status)) {
if (res.status === 401 || res.status === 403) {
onSessionExpiredCallback?.();
return null;
}

View File

@ -1,3 +1,5 @@
import api from '../auth/apiClient.js';
// ============= handleCareerClick =============
const handleCareerClick = useCallback(
async (career) => {
@ -34,7 +36,7 @@
// Salary
let salaryResponse;
try {
salaryResponse = await axios.get('/api/salary', {
salaryResponse = await api.get('/api/salary', {
params: { socCode: socCode.split('.')[0], area: areaTitle },
});
} catch (error) {
@ -46,7 +48,7 @@
// Economic
let economicResponse;
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"
});
} catch (error) {
@ -56,7 +58,7 @@
// Tuition
let tuitionResponse;
try {
tuitionResponse = await axios.get('/api/tuition', {
tuitionResponse = await api.get('/api/tuition', {
params: { cipCode: cleanedCipCode, state: userState },
});
} 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:';
// Whitelist + TTL (ms)
const ALLOW = {
theme: 30*24*3600*1000, // 30d
layoutMode: 30*24*3600*1000, // 30d
lastPanel: 24*3600*1000, // 1d
flagsVersion: 6*3600*1000, // 6h
lastCareerName: 24*3600*1000, // 1d
theme: 30 * 24 * 3600 * 1000, // 30d
layoutMode: 30 * 24 * 3600 * 1000, // 30d
lastPanel: 1 * 24 * 3600 * 1000, // 1d
flagsVersion: 6 * 3600 * 1000, // 6h
lastCareerName: 1 * 24 * 3600 * 1000, // 1d
};
function _ns(k) { return `${NS}${k}`; }
// Strict setter: only allow whitelisted keys
export function setItem(key, value) {
if (!(key in ALLOW)) throw new Error(`[safeLocal] Not allowed: ${key}`);
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) {
if (!(key in ALLOW)) return null;
const raw = localStorage.getItem(NS + key); if (!raw) return null;
try { const { v, e } = JSON.parse(raw); if (e && e < Date.now()) { localStorage.removeItem(NS + key); return null; } return v; }
catch { localStorage.removeItem(NS + key); return null; }
try {
const raw = localStorage.getItem(_ns(key));
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
const RESTRICTED_SUBSTRINGS = [
'token','access','refresh','userid','user_id','user','profile','email','phone',
'answers','interest','riasec','salary','ssn','auth'
];
function shouldBlock(key) {
const k = String(key || '').toLowerCase();
return RESTRICTED_SUBSTRINGS.some(s => k.includes(s));
// src/utils/storageGuard.js
// Blocks obviously sensitive keys from being written to Web Storage.
// Shows a loud console error so you catch any accidental writes.
const SENSITIVE_KEYS = new Set([
'token',
'accessToken',
'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;
const _set = storage.setItem.bind(storage);
storage.setItem = (k, v) => {
if (shouldBlock(k)) {
throw new Error(`[storageGuard] Blocked setItem(\"${k}\"). Sensitive data is not allowed in Web Storage.`);
export function installStorageGuard({ noisy = true } = {}) {
if (typeof window === 'undefined' || !window.localStorage) return () => {};
const origSetItem = Storage.prototype.setItem;
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; // block write
}
} catch {
// fall through to original on any unexpected error
}
return _set(k, v);
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';
const testOutputFile = '../../test_careers_with_ratings.json';
@ -21,7 +21,7 @@ const mapScoreToScale = (score) => {
const fetchCareerRatingsTest = async (socCode) => {
try {
const response = await axios.get(
const response = await api.get(
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
{ 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);
});