diff --git a/.build.hash b/.build.hash new file mode 100644 index 0000000..8d73de0 --- /dev/null +++ b/.build.hash @@ -0,0 +1 @@ +fcb1ff42e88c57ae313a74da813f6a3cdb19904f-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b diff --git a/.cache/node_6bc103a90d2491f8de0d5e7175ea20168c018361f1798339730744a59c3287f1.ok b/.cache/node_6bc103a90d2491f8de0d5e7175ea20168c018361f1798339730744a59c3287f1.ok new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore index 595c5ec..b21ab22 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ yarn-error.log* _logout env/*.env *.env -uploads/ \ No newline at end of file +uploads/.env +.env +.env.* +scan-env.sh diff --git a/.last-lock b/.last-lock new file mode 100644 index 0000000..46734e2 --- /dev/null +++ b/.last-lock @@ -0,0 +1 @@ +8eca4afbc834297a74d0c140a17e370c19102dea diff --git a/.last-node b/.last-node new file mode 100644 index 0000000..5f53e87 --- /dev/null +++ b/.last-node @@ -0,0 +1 @@ +v20.19.0 diff --git a/.lock.hash b/.lock.hash new file mode 100644 index 0000000..46734e2 --- /dev/null +++ b/.lock.hash @@ -0,0 +1 @@ +8eca4afbc834297a74d0c140a17e370c19102dea diff --git a/.woodpecker.yml b/.woodpecker.yml index 99959c9..dfd486a 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -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"' diff --git a/backend/jobs/reminderCron.js b/backend/jobs/reminderCron.js index 090f643..a03b808 100644 --- a/backend/jobs/reminderCron.js +++ b/backend/jobs/reminderCron.js @@ -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); diff --git a/backend/server1.js b/backend/server1.js index 7d576ae..239d93c 100755 --- a/backend/server1.js +++ b/backend/server1.js @@ -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 }); +}); /* ------------------------------------------------------------------ diff --git a/backend/server2.js b/backend/server2.js index f7e7ad5..7d1c8a4 100755 --- a/backend/server2.js +++ b/backend/server2.js @@ -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 **************************************************/ diff --git a/backend/server3.js b/backend/server3.js index 3524a4a..5e8cb15 100644 --- a/backend/server3.js +++ b/backend/server3.js @@ -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 server2’s 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 (1–3 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 { diff --git a/backend/shared/requireAuth.js b/backend/shared/requireAuth.js index c78de68..02d98ed 100644 --- a/backend/shared/requireAuth.js +++ b/backend/shared/requireAuth.js @@ -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.' }); } diff --git a/backend/utils/authenticateUser.js b/backend/utils/authenticateUser.js index 46d200e..afa0e5c 100644 --- a/backend/utils/authenticateUser.js +++ b/backend/utils/authenticateUser.js @@ -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: }` - * 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" }); } } diff --git a/backend/utils/onboardingDraftApi.js b/backend/utils/onboardingDraftApi.js new file mode 100644 index 0000000..4a0a7f0 --- /dev/null +++ b/backend/utils/onboardingDraftApi.js @@ -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 } +} diff --git a/deploy_all.sh b/deploy_all.sh index bb857d1..4d7bd9f 100755 --- a/deploy_all.sh +++ b/deploy_all.sh @@ -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 front‑end 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 # don’t 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" \ No newline at end of file +echo "✅ Deployment finished" diff --git a/docker-compose.yml b/docker-compose.yml index df2fa3c..e0c992d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,8 +3,6 @@ # Every secret is exported from fetch‑secrets.sh and injected at deploy time. # --------------------------------------------------------------------------- x-env: &with-env - env_file: - - .env # committed, non‑secret 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 diff --git a/migrate_encrypted_columns.sql b/migrate_encrypted_columns.sql index a6d9b93..5a1ed4d 100644 --- a/migrate_encrypted_columns.sql +++ b/migrate_encrypted_columns.sql @@ -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; diff --git a/nginx.conf b/nginx.conf index 9dea7ea..edb94ab 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; } diff --git a/package-lock.json b/package-lock.json index 75f4ed5..82b7640 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index b95ffc8..49d1b7e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.js b/src/App.js index 9e69c76..19104e3 100644 --- a/src/App.js +++ b/src/App.js @@ -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 ; @@ -165,6 +162,54 @@ const showPremiumCTA = !premiumPaths.some(p => } } + // ============================== +// 1) Single Rehydrate UseEffect +// ============================== +useEffect(() => { + let cancelled = false; + + // Don’t 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 you’re 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 diff --git a/src/auth/apiClient.js b/src/auth/apiClient.js new file mode 100644 index 0000000..2fcdc28 --- /dev/null +++ b/src/auth/apiClient.js @@ -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; + + diff --git a/src/auth/apiFetch.js b/src/auth/apiFetch.js index 9836fea..18b1ec4 100644 --- a/src/auth/apiFetch.js +++ b/src/auth/apiFetch.js @@ -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 }); -} \ No newline at end of file + + return fetch(input, { + ...init, + headers, + credentials: 'include' // ← send cookie + }); +} diff --git a/src/auth/authMemory.js b/src/auth/authMemory.js index fd36957..e3d4551 100644 --- a/src/auth/authMemory.js +++ b/src/auth/authMemory.js @@ -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; -} \ No newline at end of file +} diff --git a/src/components/BillingResult.js b/src/components/BillingResult.js index a371b35..29a26c8 100644 --- a/src/components/BillingResult.js +++ b/src/components/BillingResult.js @@ -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]); /* ───────────────────────────────────────────────────────── diff --git a/src/components/CareerCoach.js b/src/components/CareerCoach.js index 8a6238a..c42a6d8 100644 --- a/src/components/CareerCoach.js +++ b/src/components/CareerCoach.js @@ -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) { diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js index 9bdf272..2c14fc2 100644 --- a/src/components/CareerExplorer.js +++ b/src/components/CareerExplorer.js @@ -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 (best‑effort) - 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); diff --git a/src/components/CareerModal.js b/src/components/CareerModal.js index 4a56c01..c806627 100644 --- a/src/components/CareerModal.js +++ b/src/components/CareerModal.js @@ -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 ( -
+

Loading career details...

@@ -61,7 +60,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { return ( -
+
@@ -88,11 +87,8 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { {/* AI RISK SECTION */} - {loadingRisk && ( -

Loading AI risk…

- )} - {!loadingRisk && aiRisk && aiRisk.riskLevel && aiRisk.reasoning && ( + {aiRisk && aiRisk.riskLevel && aiRisk.reasoning && (
AI Risk Level: {aiRisk.riskLevel}
@@ -100,7 +96,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
)} - {!loadingRisk && !aiRisk && ( + {!aiRisk && (

No AI risk data available

)}
@@ -181,10 +177,10 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { {row.percentile} - ${row.regionalSalary.toLocaleString()} + {Number.isFinite(row.regionalSalary) ? `$${fmt(row.regionalSalary)}` : '—'} - ${row.nationalSalary.toLocaleString()} + {Number.isFinite(row.nationalSalary) ? `$${fmt(row.nationalSalary)}` : '—'} ))} @@ -219,12 +215,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { {careerDetails.economicProjections.state && ( - {careerDetails.economicProjections.state.base.toLocaleString()} + {fmt(careerDetails.economicProjections.state.base)} )} {careerDetails.economicProjections.national && ( - {careerDetails.economicProjections.national.base.toLocaleString()} + {fmt(careerDetails.economicProjections.national.base)} )} @@ -234,12 +230,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { {careerDetails.economicProjections.state && ( - {careerDetails.economicProjections.state.projection.toLocaleString()} + {fmt(careerDetails.economicProjections.state.projection)} )} {careerDetails.economicProjections.national && ( - {careerDetails.economicProjections.national.projection.toLocaleString()} + {fmt(careerDetails.economicProjections.national.projection)} )} @@ -247,12 +243,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { Growth % {careerDetails.economicProjections.state && ( - {careerDetails.economicProjections.state.percentChange}% + {fmt(careerDetails.economicProjections.state.percentChange)}% )} {careerDetails.economicProjections.national && ( - {careerDetails.economicProjections.national.percentChange}% + {fmt(careerDetails.economicProjections.national.percentChange)}% )} @@ -262,12 +258,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) { {careerDetails.economicProjections.state && ( - {careerDetails.economicProjections.state.annualOpenings.toLocaleString()} + {fmt(careerDetails.economicProjections.state.annualOpenings)} )} {careerDetails.economicProjections.national && ( - {careerDetails.economicProjections.national.annualOpenings.toLocaleString()} + {fmt(careerDetails.economicProjections.national.annualOpenings)} )} diff --git a/src/components/CareerProfileList.js b/src/components/CareerProfileList.js index 1647c34..f8da03f 100644 --- a/src/components/CareerProfileList.js +++ b/src/components/CareerProfileList.js @@ -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 (

Career Profiles

diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js index 2ed1344..72017d1 100644 --- a/src/components/CareerRoadmap.js +++ b/src/components/CareerRoadmap.js @@ -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); diff --git a/src/components/ChatDrawer.js b/src/components/ChatDrawer.js index 16adb01..478a847 100644 --- a/src/components/ChatDrawer.js +++ b/src/components/ChatDrawer.js @@ -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) { diff --git a/src/components/Chatbot.js b/src/components/Chatbot.js deleted file mode 100644 index 9bbbcc4..0000000 --- a/src/components/Chatbot.js +++ /dev/null @@ -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! I’m 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 ( -
- {/* Header Bar for Minimize/Maximize */} -
- Career Chatbot - -
- - {/* If not minimized, show the chat messages and input */} - {!isMinimized && ( - <> -
- {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 ( -
- {msg.content} -
- ); - })} - {loading &&
Typing...
} -
- -
- setInput(e.target.value)} - disabled={loading} - /> - -
- - )} -
- ); -}; - -export default Chatbot; diff --git a/src/components/CollegeProfileForm.js b/src/components/CollegeProfileForm.js index bf235ec..716182f 100644 --- a/src/components/CollegeProfileForm.js +++ b/src/components/CollegeProfileForm.js @@ -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; +})(); /* ──────────────────────────────────────────────────────────── Auto‑calculate PROGRAM LENGTH when the user hasn’t typed in diff --git a/src/components/CollegeProfileList.js b/src/components/CollegeProfileList.js index 84c0d82..4d85080 100644 --- a/src/components/CollegeProfileList.js +++ b/src/components/CollegeProfileList.js @@ -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 (
{/* ───────── header row ───────── */} diff --git a/src/components/EducationalProgramsPage.js b/src/components/EducationalProgramsPage.js index 091c365..a90780e 100644 --- a/src/components/EducationalProgramsPage.js +++ b/src/components/EducationalProgramsPage.js @@ -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) { 'You’re 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 (
diff --git a/src/components/Paywall.js b/src/components/Paywall.js index b8636a6..73de1f1 100644 --- a/src/components/Paywall.js +++ b/src/components/Paywall.js @@ -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

Loading …

; + if (!sub) return

Loading…

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

Your plan: {plan}

-

- Manage payment method, invoices or cancel anytime. -

+

Manage payment method, invoices or cancel anytime.

- + +
- {/* Pro tier */} + {/* Pro */}

Pro Premium

  • Everything in Premium
  • -
  • Priority GPT‑4o usage & higher rate limits
  • -
  • 5 × resume optimizations / week
  • +
  • Priority GPT-4o usage & higher rate limits
  • +
  • 5 × resume optimizations / week
- - + +
diff --git a/src/components/PremiumOnboarding/CollegeOnboarding.js b/src/components/PremiumOnboarding/CollegeOnboarding.js index 63adc4d..8c1ac54 100644 --- a/src/components/PremiumOnboarding/CollegeOnboarding.js +++ b/src/components/PremiumOnboarding/CollegeOnboarding.js @@ -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) { diff --git a/src/components/PremiumOnboarding/OnboardingContainer.js b/src/components/PremiumOnboarding/OnboardingContainer.js index 9f9ed98..df581e7 100644 --- a/src/components/PremiumOnboarding/OnboardingContainer.js +++ b/src/components/PremiumOnboarding/OnboardingContainer.js @@ -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.length‑1 - 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 = [ , - - , - - /* insert **only if** the user did NOT press “Skip for now” */ - ...(!skipFin - ? [ - , - ] - : []), - - , - - , + , + ...(!skipFin ? [ ] : []), + , + ]; - return
{onboardingSteps[step]}
; -}; - -export default OnboardingContainer; + const safeIndex = Math.min(step, steps.length - 1); + return
{loaded ? steps[safeIndex] : null}
; +} diff --git a/src/components/ResumeRewrite.js b/src/components/ResumeRewrite.js index b0e1965..8a48705 100644 --- a/src/components/ResumeRewrite.js +++ b/src/components/ResumeRewrite.js @@ -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() {
- - + Upload Resume (PDF or DOCX): + +
- setJobTitle(e.target.value)} + { + 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() {
-