removed files from tracking, dependencies, fixed encryption
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
This commit is contained in:
parent
2d9e63af32
commit
5838f782e7
1
.build.hash
Normal file
1
.build.hash
Normal file
@ -0,0 +1 @@
|
||||
fcb1ff42e88c57ae313a74da813f6a3cdb19904f-803b2c2ecad09a0fbca070296808a53489de891a-e9eccd451b778829eb2f2c9752c670b707e1268b
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -22,4 +22,7 @@ yarn-error.log*
|
||||
_logout
|
||||
env/*.env
|
||||
*.env
|
||||
uploads/
|
||||
uploads/.env
|
||||
.env
|
||||
.env.*
|
||||
scan-env.sh
|
||||
|
1
.last-lock
Normal file
1
.last-lock
Normal file
@ -0,0 +1 @@
|
||||
8eca4afbc834297a74d0c140a17e370c19102dea
|
1
.last-node
Normal file
1
.last-node
Normal file
@ -0,0 +1 @@
|
||||
v20.19.0
|
1
.lock.hash
Normal file
1
.lock.hash
Normal file
@ -0,0 +1 @@
|
||||
8eca4afbc834297a74d0c140a17e370c19102dea
|
@ -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"'
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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 });
|
||||
});
|
||||
|
||||
|
||||
/* ------------------------------------------------------------------
|
||||
|
@ -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
|
||||
**************************************************/
|
||||
|
@ -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 {
|
||||
|
@ -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.' });
|
||||
}
|
||||
|
||||
|
@ -1,19 +1,28 @@
|
||||
import jwt from "jsonwebtoken";
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
const COOKIE_NAME = process.env.COOKIE_NAME || 'aptiva_session';
|
||||
|
||||
/**
|
||||
* Adds `req.user = { id: <user_profile.id> }`
|
||||
* If no or bad token ➜ 401.
|
||||
* Adds `req.user = { id }`
|
||||
* Accepts either Bearer token or httpOnly cookie.
|
||||
* 401 on missing; 401 again on invalid/expired.
|
||||
*/
|
||||
export default function authenticateUser(req, res, next) {
|
||||
const token = req.headers.authorization?.split(" ")[1];
|
||||
let token = req.headers.authorization?.startsWith('Bearer ')
|
||||
? req.headers.authorization.split(' ')[1]
|
||||
: null;
|
||||
|
||||
if (!token) {
|
||||
token = req.cookies?.[COOKIE_NAME] || req.cookies?.token || null;
|
||||
}
|
||||
|
||||
if (!token) return res.status(401).json({ error: "Authorization token required" });
|
||||
|
||||
try {
|
||||
const { id } = jwt.verify(token, JWT_SECRET);
|
||||
req.user = { id }; // attach the id for downstream use
|
||||
req.user = { id };
|
||||
next();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return res.status(401).json({ error: "Invalid or expired token" });
|
||||
}
|
||||
}
|
||||
|
26
backend/utils/onboardingDraftApi.js
Normal file
26
backend/utils/onboardingDraftApi.js
Normal file
@ -0,0 +1,26 @@
|
||||
// src/backend/utils/onboardingDraftApi.js
|
||||
import authFetch from '../../utils/authFetch.js';
|
||||
|
||||
const DRAFT_URL = '/api/premium/onboarding/draft';
|
||||
|
||||
export async function loadDraft() {
|
||||
const res = await authFetch(DRAFT_URL);
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`loadDraft failed: ${res.status}`);
|
||||
return res.json(); // -> { id, step, data } | null
|
||||
}
|
||||
|
||||
export async function saveDraft({ id, step = 0, data = {} }) {
|
||||
const res = await authFetch(DRAFT_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id, step, data })
|
||||
});
|
||||
if (!res.ok) throw new Error(`saveDraft failed: ${res.status}`);
|
||||
return res.json(); // -> { id, step }
|
||||
}
|
||||
|
||||
export async function clearDraft() {
|
||||
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
|
||||
if (!res.ok) throw new Error(`clearDraft failed: ${res.status}`);
|
||||
return res.json(); // -> { ok: true }
|
||||
}
|
144
deploy_all.sh
144
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"
|
||||
echo "✅ Deployment finished"
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -54,7 +54,7 @@ http {
|
||||
location ^~ /api/tuition/ { proxy_pass http://backend5001; }
|
||||
location ^~ /api/projections/ { proxy_pass http://backend5001; }
|
||||
location ^~ /api/skills/ { proxy_pass http://backend5001; }
|
||||
location ^~ /api/ai-risk { proxy_pass http://backend5002; }
|
||||
location ^~ /api/ai-risk { proxy_pass http://backend5001; }
|
||||
location ^~ /api/maps/distance { proxy_pass http://backend5001; }
|
||||
location ^~ /api/schools { proxy_pass http://backend5001; }
|
||||
location ^~ /api/support { proxy_pass http://backend5001; }
|
||||
|
23
package-lock.json
generated
23
package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
165
src/App.js
165
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 <ResetPassword />;
|
||||
@ -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
|
||||
|
38
src/auth/apiClient.js
Normal file
38
src/auth/apiClient.js
Normal file
@ -0,0 +1,38 @@
|
||||
// src/apiClient.js
|
||||
import axios from 'axios';
|
||||
import { getToken, clearToken } from './authMemory.js';
|
||||
|
||||
// sane defaults
|
||||
axios.defaults.withCredentials = true; // send cookies to same-origin /api when needed
|
||||
axios.defaults.timeout = 20000;
|
||||
|
||||
// attach Authorization from in-memory token
|
||||
axios.interceptors.request.use((config) => {
|
||||
const t = getToken();
|
||||
if (t) {
|
||||
config.headers = config.headers || {};
|
||||
if (!config.headers.Authorization) {
|
||||
config.headers.Authorization = `Bearer ${t}`;
|
||||
}
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// central 401 handling (optional)
|
||||
axios.interceptors.response.use(
|
||||
r => r,
|
||||
(err) => {
|
||||
const status = err?.response?.status;
|
||||
if (status === 401) {
|
||||
clearToken();
|
||||
// ping your SessionExpiredHandler (you already mount it)
|
||||
window.dispatchEvent(new CustomEvent('aptiva:session-expired'));
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default axios; // optional; callers can still `import axios from "axios"`
|
||||
export const api = axios;
|
||||
|
||||
|
@ -1,9 +1,15 @@
|
||||
// apiFetch.js
|
||||
import { getToken } from './authMemory.js';
|
||||
// src/auth/apiFetch.js
|
||||
import { getToken, clearToken } from './authMemory.js';
|
||||
|
||||
export async function apiFetch(input, init = {}) {
|
||||
export default function apiFetch(input, init = {}) {
|
||||
const headers = new Headers(init.headers || {});
|
||||
const t = getToken();
|
||||
// optional: add Bearer if you *happen* to have one in memory
|
||||
const t = window.__auth?.get?.();
|
||||
if (t) headers.set('Authorization', `Bearer ${t}`);
|
||||
return fetch(input, { ...init, headers });
|
||||
}
|
||||
|
||||
return fetch(input, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: 'include' // ← send cookie
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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]);
|
||||
|
||||
/* ─────────────────────────────────────────────────────────
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import isAllOther from '../utils/isAllOther.js';
|
||||
|
||||
@ -34,9 +33,9 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
);
|
||||
}
|
||||
|
||||
if (!careerDetails?.salaryData === undefined) {
|
||||
if (!careerDetails || careerDetails.salaryData === undefined) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center z-50">
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50" role="dialog" aria-modal="true">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<p className="text-lg text-gray-700">Loading career details...</p>
|
||||
</div>
|
||||
@ -61,7 +60,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50">
|
||||
<div className="fixed inset-0 bg-gray-900 bg-opacity-70 flex justify-center items-center overflow-auto z-50" role="dialog" aria-modal="true">
|
||||
<div className="bg-white rounded-lg shadow-lg w-full max-w-5xl p-6 m-4 max-h-[90vh] overflow-y-auto">
|
||||
|
||||
|
||||
@ -88,11 +87,8 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
</h2>
|
||||
|
||||
{/* AI RISK SECTION */}
|
||||
{loadingRisk && (
|
||||
<p className="text-sm text-gray-500 mt-1">Loading AI risk…</p>
|
||||
)}
|
||||
|
||||
{!loadingRisk && aiRisk && aiRisk.riskLevel && aiRisk.reasoning && (
|
||||
{aiRisk && aiRisk.riskLevel && aiRisk.reasoning && (
|
||||
<div className="text-sm text-gray-500 mt-1">
|
||||
<strong>AI Risk Level:</strong> {aiRisk.riskLevel}
|
||||
<br />
|
||||
@ -100,7 +96,7 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingRisk && !aiRisk && (
|
||||
{!aiRisk && (
|
||||
<p className="text-sm text-gray-500 mt-1">No AI risk data available</p>
|
||||
)}
|
||||
</div>
|
||||
@ -181,10 +177,10 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
<tr key={i}>
|
||||
<td className="px-3 py-2 border-b">{row.percentile}</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
${row.regionalSalary.toLocaleString()}
|
||||
{Number.isFinite(row.regionalSalary) ? `$${fmt(row.regionalSalary)}` : '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2 border-b">
|
||||
${row.nationalSalary.toLocaleString()}
|
||||
{Number.isFinite(row.nationalSalary) ? `$${fmt(row.nationalSalary)}` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -219,12 +215,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
</td>
|
||||
{careerDetails.economicProjections.state && (
|
||||
<td className="px-3 py-2 border-b">
|
||||
{careerDetails.economicProjections.state.base.toLocaleString()}
|
||||
{fmt(careerDetails.economicProjections.state.base)}
|
||||
</td>
|
||||
)}
|
||||
{careerDetails.economicProjections.national && (
|
||||
<td className="px-3 py-2 border-b">
|
||||
{careerDetails.economicProjections.national.base.toLocaleString()}
|
||||
{fmt(careerDetails.economicProjections.national.base)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
@ -234,12 +230,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
</td>
|
||||
{careerDetails.economicProjections.state && (
|
||||
<td className="px-3 py-2 border-b">
|
||||
{careerDetails.economicProjections.state.projection.toLocaleString()}
|
||||
{fmt(careerDetails.economicProjections.state.projection)}
|
||||
</td>
|
||||
)}
|
||||
{careerDetails.economicProjections.national && (
|
||||
<td className="px-3 py-2 border-b">
|
||||
{careerDetails.economicProjections.national.projection.toLocaleString()}
|
||||
{fmt(careerDetails.economicProjections.national.projection)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
@ -247,12 +243,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
<td className="px-3 py-2 border-b font-semibold">Growth %</td>
|
||||
{careerDetails.economicProjections.state && (
|
||||
<td className="px-3 py-2 border-b">
|
||||
{careerDetails.economicProjections.state.percentChange}%
|
||||
{fmt(careerDetails.economicProjections.state.percentChange)}%
|
||||
</td>
|
||||
)}
|
||||
{careerDetails.economicProjections.national && (
|
||||
<td className="px-3 py-2 border-b">
|
||||
{careerDetails.economicProjections.national.percentChange}%
|
||||
{fmt(careerDetails.economicProjections.national.percentChange)}%
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
@ -262,12 +258,12 @@ function CareerModal({ career, careerDetails, closeModal, addCareerToList }) {
|
||||
</td>
|
||||
{careerDetails.economicProjections.state && (
|
||||
<td className="px-3 py-2 border-b">
|
||||
{careerDetails.economicProjections.state.annualOpenings.toLocaleString()}
|
||||
{fmt(careerDetails.economicProjections.state.annualOpenings)}
|
||||
</td>
|
||||
)}
|
||||
{careerDetails.economicProjections.national && (
|
||||
<td className="px-3 py-2 border-b">
|
||||
{careerDetails.economicProjections.national.annualOpenings.toLocaleString()}
|
||||
{fmt(careerDetails.economicProjections.national.annualOpenings)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
|
@ -1,29 +1,47 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import apiFetch from '../auth/apiFetch.js';
|
||||
|
||||
export default function CareerProfileList() {
|
||||
const [rows, setRows] = useState([]);
|
||||
const nav = useNavigate();
|
||||
const token = localStorage.getItem('token');
|
||||
const nav = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/premium/career-profile/all', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(d => setRows(d.careerProfiles || []));
|
||||
}, [token]);
|
||||
(async () => {
|
||||
try {
|
||||
const r = await apiFetch('/api/premium/career-profile/all');
|
||||
if (!r.ok) {
|
||||
// apiFetch already fires session-expired on 401/403, just bail
|
||||
return;
|
||||
}
|
||||
const d = await r.json();
|
||||
setRows(d.careerProfiles || []);
|
||||
} catch (e) {
|
||||
console.error('Failed to load career profiles:', e);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function remove(id) {
|
||||
if (!window.confirm('Delete this career profile?')) return;
|
||||
await fetch(`/api/premium/career-profile/${id}`, {
|
||||
method : 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
setRows(rows.filter(r => r.id !== id));
|
||||
}
|
||||
|
||||
return (
|
||||
try {
|
||||
const r = await apiFetch(`/api/premium/career-profile/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
if (!r.ok) {
|
||||
// 401/403 will already be handled by apiFetch
|
||||
const msg = await r.text().catch(() => 'Failed to delete');
|
||||
alert(msg || 'Failed to delete');
|
||||
return;
|
||||
}
|
||||
setRows(prev => prev.filter(row => row.id !== id));
|
||||
} catch (e) {
|
||||
console.error('Delete failed:', e);
|
||||
alert('Failed to delete');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto space-y-4">
|
||||
<h2 className="text-2xl font-semibold">Career Profiles</h2>
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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 (
|
||||
<div className={`chatbot-container ${isMinimized ? "minimized" : ""}`}>
|
||||
{/* Header Bar for Minimize/Maximize */}
|
||||
<div className="chatbot-header">
|
||||
<span className="chatbot-title">Career Chatbot</span>
|
||||
<button className="minimize-btn" onClick={toggleMinimize}>
|
||||
{isMinimized ? "▼" : "▲"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* If not minimized, show the chat messages and input */}
|
||||
{!isMinimized && (
|
||||
<>
|
||||
<div className="chat-messages">
|
||||
{messages.map((msg, index) => {
|
||||
// default to 'bot' if role not user or assistant
|
||||
const roleClass =
|
||||
msg.role === "user" ? "user" : msg.role === "assistant" ? "bot" : "bot";
|
||||
return (
|
||||
<div key={index} className={`message ${roleClass}`}>
|
||||
{msg.content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{loading && <div className="message bot typing">Typing...</div>}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="chat-input-form">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Ask a question..."
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button type="submit" disabled={loading}>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Chatbot;
|
@ -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
|
||||
|
@ -2,12 +2,11 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import CareerSelectDropdown from "./CareerSelectDropdown.js";
|
||||
import authFetch from "../utils/authFetch.js";
|
||||
import apiFetch from '../auth/apiFetch.js';
|
||||
|
||||
export default function CollegeProfileList() {
|
||||
const { careerId } = useParams(); // may be undefined
|
||||
const navigate = useNavigate();
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
/* ───────── existing lists ───────── */
|
||||
const [rows, setRows] = useState([]);
|
||||
@ -17,20 +16,30 @@ export default function CollegeProfileList() {
|
||||
const [showPicker, setShowPicker] = useState(false);
|
||||
const [loadingCareers, setLoadingCareers] = useState(true);
|
||||
|
||||
/* ───────── load college plans ───────── */
|
||||
useEffect(() => {
|
||||
fetch("/api/premium/college-profile/all", {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((d) => setRows(d.collegeProfiles || []));
|
||||
}, [token]);
|
||||
const authFetch = apiFetch;
|
||||
|
||||
/* ───────── load career profiles for the picker ───────── */
|
||||
/* ───────── load college plans ───────── */
|
||||
/* ───────── load college plans ───────── */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const r = await authFetch("/api/premium/college-profile/all");
|
||||
if (!r.ok) throw new Error(`load college-profile/all → ${r.status}`);
|
||||
const d = await r.json();
|
||||
setRows(d.collegeProfiles || []);
|
||||
} catch (err) {
|
||||
console.error("College profiles load failed:", err);
|
||||
setRows([]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
/* ───────── load career profiles for the picker ───────── */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await authFetch("/api/premium/career-profile/all");
|
||||
if (!res.ok) throw new Error(`load career-profile/all → ${res.status}`);
|
||||
const data = await res.json();
|
||||
setCareerRows(data.careerProfiles || []);
|
||||
} catch (err) {
|
||||
@ -45,10 +54,10 @@ export default function CollegeProfileList() {
|
||||
async function handleDelete(id) {
|
||||
if (!window.confirm("Delete this college plan?")) return;
|
||||
try {
|
||||
await fetch(`/api/premium/college-profile/${id}`, {
|
||||
const res = await authFetch(`/api/premium/college-profile/${id}`, {
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) throw new Error(`delete failed → ${res.status}`);
|
||||
setRows((r) => r.filter((row) => row.id !== id));
|
||||
} catch (err) {
|
||||
console.error("Delete failed:", err);
|
||||
@ -56,6 +65,7 @@ export default function CollegeProfileList() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto space-y-6">
|
||||
{/* ───────── header row ───────── */}
|
||||
|
@ -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 (
|
||||
<div className="p-4">
|
||||
|
@ -1,66 +1,81 @@
|
||||
// src/components/Paywall.jsx
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from './ui/button.js';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from './ui/button.js';
|
||||
|
||||
export default function Paywall() {
|
||||
const nav = useNavigate();
|
||||
const [sub, setSub] = useState(null); // null = loading
|
||||
const token = localStorage.getItem('token') || '';
|
||||
const nav = useNavigate();
|
||||
const [sub, setSub] = useState(null); // null = loading
|
||||
|
||||
/* ───────────────── fetch current subscription ─────────────── */
|
||||
// Fetch current subscription using cookie/session auth
|
||||
useEffect(() => {
|
||||
fetch('/api/premium/subscription/status', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
})
|
||||
.then(r => r.ok ? r.json() : Promise.reject(r.status))
|
||||
.then(setSub)
|
||||
.catch(() => setSub({ is_premium:0, is_pro_premium:0 }));
|
||||
}, [token]);
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/premium/subscription/status', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
const json = await r.json();
|
||||
if (!cancelled) setSub(json);
|
||||
} catch {
|
||||
if (!cancelled) setSub({ is_premium: 0, is_pro_premium: 0 });
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
/* ───────────────── helpers ────────────────────────────────── */
|
||||
const checkout = useCallback(async (tier, cycle) => {
|
||||
const base = window.location.origin; // https://dev1.aptivaai.com
|
||||
const res = await fetch('/api/premium/stripe/create-checkout-session', {
|
||||
method : 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization : `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
cycle,
|
||||
success_url: `${base}/billing?ck=success`,
|
||||
cancel_url : `${base}/billing?ck=cancel`
|
||||
})
|
||||
});
|
||||
if (!res.ok) return console.error('Checkout failed', await res.text());
|
||||
|
||||
const { url } = await res.json();
|
||||
window.location.href = url; // redirect to Stripe
|
||||
}, [token]);
|
||||
try {
|
||||
const base = window.location.origin;
|
||||
const res = await fetch('/api/premium/stripe/create-checkout-session', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
tier,
|
||||
cycle,
|
||||
success_url: `${base}/billing?ck=success`,
|
||||
cancel_url : `${base}/billing?ck=cancel`,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error('Checkout failed', await res.text());
|
||||
return;
|
||||
}
|
||||
const { url } = await res.json();
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error('Checkout error', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openPortal = useCallback(async () => {
|
||||
const base = window.location.origin;
|
||||
const res = await fetch(`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (!res.ok) return console.error('Portal error', await res.text());
|
||||
window.location.href = (await res.json()).url;
|
||||
}, [token]);
|
||||
try {
|
||||
const base = window.location.origin;
|
||||
const res = await fetch(
|
||||
`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(base + '/billing')}`,
|
||||
{ credentials: 'include' }
|
||||
);
|
||||
if (!res.ok) {
|
||||
console.error('Portal error', await res.text());
|
||||
return;
|
||||
}
|
||||
const { url } = await res.json();
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error('Portal open error', err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* ───────────────── render ─────────────────────────────────── */
|
||||
if (!sub) return <p className="p-6 text-center text-sm">Loading …</p>;
|
||||
if (!sub) return <p className="p-6 text-center text-sm">Loading…</p>;
|
||||
|
||||
if (sub.is_premium || sub.is_pro_premium) {
|
||||
if (sub.is_premium || sub.is_pro_premium) {
|
||||
const plan = sub.is_pro_premium ? 'Pro Premium' : 'Premium';
|
||||
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-6 text-center space-y-4">
|
||||
<h2 className="text-xl font-semibold">Your plan: {plan}</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Manage payment method, invoices or cancel anytime.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Manage payment method, invoices or cancel anytime.</p>
|
||||
|
||||
<Button onClick={openPortal} className="w-full">
|
||||
Manage subscription
|
||||
@ -73,43 +88,41 @@ export default function Paywall() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── no active sub => show the pricing choices ──────────────── */
|
||||
// No active sub => pricing
|
||||
return (
|
||||
<div className="max-w-lg mx-auto p-6 space-y-8">
|
||||
<header className="text-center">
|
||||
<h2 className="text-2xl font-semibold">Upgrade to AptivaAI</h2>
|
||||
<p className="text-sm text-gray-600">
|
||||
Choose the plan that fits your needs – cancel anytime.
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">Choose the plan that fits your needs – cancel anytime.</p>
|
||||
</header>
|
||||
|
||||
{/* Premium tier */}
|
||||
{/* Premium */}
|
||||
<section className="border rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-lg font-medium">Premium</h3>
|
||||
<ul className="text-sm list-disc list-inside space-y-1">
|
||||
<li>Career milestone planning</li>
|
||||
<li>Financial projections & benchmarks</li>
|
||||
<li>2 × resume optimizations / week</li>
|
||||
<li>2 × resume optimizations / week</li>
|
||||
</ul>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button onClick={() => checkout('premium', 'monthly')}>$4.99 / mo</Button>
|
||||
<Button onClick={() => checkout('premium', 'annual' )}>$49 / yr</Button>
|
||||
<Button onClick={() => checkout('premium', 'monthly')}>$4.99 / mo</Button>
|
||||
<Button onClick={() => checkout('premium', 'annual')}>$49 / yr</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Pro tier */}
|
||||
{/* Pro */}
|
||||
<section className="border rounded-lg p-4 space-y-4">
|
||||
<h3 className="text-lg font-medium">Pro Premium</h3>
|
||||
<ul className="text-sm list-disc list-inside space-y-1">
|
||||
<li>Everything in Premium</li>
|
||||
<li>Priority GPT‑4o usage & higher rate limits</li>
|
||||
<li>5 × resume optimizations / week</li>
|
||||
<li>Priority GPT-4o usage & higher rate limits</li>
|
||||
<li>5 × resume optimizations / week</li>
|
||||
</ul>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Button onClick={() => checkout('pro', 'monthly')}>$7.99 / mo</Button>
|
||||
<Button onClick={() => checkout('pro', 'annual' )}>$79 / yr</Button>
|
||||
<Button onClick={() => checkout('pro', 'monthly')}>$7.99 / mo</Button>
|
||||
<Button onClick={() => checkout('pro', 'annual')}>$79 / yr</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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 = [
|
||||
<PremiumWelcome nextStep={nextStep} />,
|
||||
|
||||
<CareerOnboarding
|
||||
nextStep={nextStep}
|
||||
finishNow={finishImmediately}
|
||||
data={careerData}
|
||||
setData={setCareerData}
|
||||
/>,
|
||||
|
||||
/* insert **only if** the user did NOT press “Skip for now” */
|
||||
...(!skipFin
|
||||
? [
|
||||
<FinancialOnboarding
|
||||
key="fin"
|
||||
nextStep={nextStep}
|
||||
prevStep={prevStep}
|
||||
data={{
|
||||
...financialData,
|
||||
currently_working: careerData.currently_working,
|
||||
}}
|
||||
setData={setFinancialData}
|
||||
/>,
|
||||
]
|
||||
: []),
|
||||
|
||||
<CollegeOnboarding
|
||||
prevStep={prevStep}
|
||||
nextStep={nextStep}
|
||||
data={{
|
||||
...collegeData,
|
||||
college_enrollment_status: careerData.college_enrollment_status,
|
||||
}}
|
||||
setData={setCollegeData}
|
||||
/>,
|
||||
|
||||
<ReviewPage
|
||||
careerData={careerData}
|
||||
financialData={financialData}
|
||||
collegeData={collegeData}
|
||||
onSubmit={handleFinalSubmit}
|
||||
onBack={prevStep}
|
||||
/>,
|
||||
<CareerOnboarding nextStep={nextStep} finishNow={finishImmediately} data={careerData} setData={setCareerData} />,
|
||||
...(!skipFin ? [ <FinancialOnboarding key="fin" nextStep={nextStep} prevStep={prevStep}
|
||||
data={{ ...financialData, currently_working: careerData.currently_working }} setData={setFinancialData} /> ] : []),
|
||||
<CollegeOnboarding prevStep={prevStep} nextStep={nextStep}
|
||||
data={{ ...collegeData, college_enrollment_status: careerData.college_enrollment_status }} setData={setCollegeData} />,
|
||||
<ReviewPage careerData={careerData} financialData={financialData} collegeData={collegeData}
|
||||
onSubmit={handleFinalSubmit} onBack={prevStep} />
|
||||
];
|
||||
|
||||
return <div>{onboardingSteps[step]}</div>;
|
||||
};
|
||||
|
||||
export default OnboardingContainer;
|
||||
const safeIndex = Math.min(step, steps.length - 1);
|
||||
return <div>{loaded ? steps[safeIndex] : null}</div>;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import api from '../auth/apiClient.js';
|
||||
|
||||
function ResumeRewrite() {
|
||||
const [resumeFile, setResumeFile] = useState(null);
|
||||
@ -9,20 +9,44 @@ function ResumeRewrite() {
|
||||
const [error, setError] = useState('');
|
||||
const [remainingOptimizations, setRemainingOptimizations] = useState(null);
|
||||
const [resetDate, setResetDate] = useState(null);
|
||||
const [loading, setLoading] = useState(false); // ADDED loading state
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const ALLOWED_TYPES = [
|
||||
'application/pdf',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
|
||||
];
|
||||
const MAX_MB = 5;
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
setResumeFile(e.target.files[0]);
|
||||
const f = e.target.files?.[0] || null;
|
||||
setOptimizedResume('');
|
||||
setError('');
|
||||
|
||||
if (!f) {
|
||||
setResumeFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic client-side validation
|
||||
if (!ALLOWED_TYPES.includes(f.type)) {
|
||||
setError('Please upload a PDF or DOCX file.');
|
||||
setResumeFile(null);
|
||||
return;
|
||||
}
|
||||
if (f.size > MAX_MB * 1024 * 1024) {
|
||||
setError(`File is too large. Maximum ${MAX_MB}MB.`);
|
||||
setResumeFile(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setResumeFile(f);
|
||||
};
|
||||
|
||||
const fetchRemainingOptimizations = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const res = await axios.get('/api/premium/resume/remaining', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
const res = await api.get('/api/premium/resume/remaining', { withCredentials: true });
|
||||
setRemainingOptimizations(res.data.remainingOptimizations);
|
||||
setResetDate(new Date(res.data.resetDate).toLocaleDateString());
|
||||
setResetDate(res.data.resetDate ? new Date(res.data.resetDate).toLocaleDateString() : null);
|
||||
} catch (err) {
|
||||
console.error('Error fetching optimizations:', err);
|
||||
setError('Could not fetch optimization limits.');
|
||||
@ -35,25 +59,24 @@ function ResumeRewrite() {
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setOptimizedResume('');
|
||||
|
||||
if (!resumeFile || !jobTitle.trim() || !jobDescription.trim()) {
|
||||
setError('Please fill in all fields.');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true); // ACTIVATE loading
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
const formData = new FormData();
|
||||
formData.append('resumeFile', resumeFile);
|
||||
formData.append('jobTitle', jobTitle);
|
||||
formData.append('jobDescription', jobDescription);
|
||||
formData.append('jobTitle', jobTitle.trim());
|
||||
formData.append('jobDescription', jobDescription.trim());
|
||||
|
||||
const res = await axios.post('/api/premium/resume/optimize', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
// Let axios/browser set multipart boundary automatically; just include credentials.
|
||||
const res = await api.post('/api/premium/resume/optimize', formData, {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
setOptimizedResume(res.data.optimizedResume || '');
|
||||
@ -63,7 +86,7 @@ function ResumeRewrite() {
|
||||
console.error('Resume optimization error:', err);
|
||||
setError(err.response?.data?.error || 'Failed to optimize resume.');
|
||||
} finally {
|
||||
setLoading(false); // DEACTIVATE loading
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -80,15 +103,26 @@ function ResumeRewrite() {
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Upload Resume (PDF or DOCX):</label>
|
||||
<input type="file" accept=".pdf,.docx" onChange={handleFileChange}
|
||||
<label className="block font-medium text-gray-700 mb-1">
|
||||
Upload Resume (PDF or DOCX):
|
||||
</label>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.docx"
|
||||
onChange={handleFileChange}
|
||||
className="file:mr-4 file:py-2 file:px-4 file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100 cursor-pointer text-gray-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Job Title:</label>
|
||||
<input type="text" value={jobTitle} onChange={(e) => setJobTitle(e.target.value)}
|
||||
<input
|
||||
type="text"
|
||||
value={jobTitle}
|
||||
onChange={(e) => {
|
||||
setJobTitle(e.target.value);
|
||||
if (error) setError('');
|
||||
}}
|
||||
className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200"
|
||||
placeholder="e.g., Software Engineer"
|
||||
/>
|
||||
@ -96,8 +130,14 @@ function ResumeRewrite() {
|
||||
|
||||
<div>
|
||||
<label className="block font-medium text-gray-700 mb-1">Job Description:</label>
|
||||
<textarea value={jobDescription} onChange={(e) => setJobDescription(e.target.value)}
|
||||
rows={4} className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200"
|
||||
<textarea
|
||||
value={jobDescription}
|
||||
onChange={(e) => {
|
||||
setJobDescription(e.target.value);
|
||||
if (error) setError('');
|
||||
}}
|
||||
rows={4}
|
||||
className="w-full border rounded px-3 py-2 focus:outline-none focus:ring focus:ring-blue-200"
|
||||
placeholder="Paste the job listing or requirements here..."
|
||||
/>
|
||||
</div>
|
||||
@ -125,7 +165,9 @@ function ResumeRewrite() {
|
||||
{optimizedResume && (
|
||||
<div className="mt-8">
|
||||
<h3 className="text-xl font-bold mb-2">Optimized Resume</h3>
|
||||
<pre className="whitespace-pre-wrap bg-gray-50 p-4 rounded border">{optimizedResume}</pre>
|
||||
<pre className="whitespace-pre-wrap bg-gray-50 p-4 rounded border">
|
||||
{optimizedResume}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
@ -104,11 +104,35 @@ export default function RetirementChatBar({
|
||||
const [forceCtx, setForceCtx] = useState(false);
|
||||
const [scenarios, setScenarios] = useState([]);
|
||||
const [currentScenario, setCurrentScenario] = useState(scenario);
|
||||
const [threadId, setThreadId] = useState(null);
|
||||
const bottomRef = useRef(null);
|
||||
|
||||
/* wipe chat on scenario change */
|
||||
useEffect(() => setChatHistory([]), [currentScenario?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (!currentScenario?.id) return;
|
||||
const r = await authFetch('/api/premium/retire/chat/threads');
|
||||
const { threads = [] } = await r.json();
|
||||
let id = threads.find(Boolean)?.id;
|
||||
if (!id) {
|
||||
const r2 = await authFetch('/api/premium/retire/chat/threads', {
|
||||
method:'POST',
|
||||
headers:{ 'Content-Type':'application/json' },
|
||||
body: JSON.stringify({ title: `Retirement • ${scenarioLabel}` })
|
||||
});
|
||||
({ id } = await r2.json());
|
||||
}
|
||||
setThreadId(id);
|
||||
const r3 = await authFetch(`/api/premium/retire/chat/threads/${id}`);
|
||||
const { messages: msgs = [] } = await r3.json();
|
||||
setChatHistory(msgs);
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentScenario?.id]);
|
||||
|
||||
|
||||
/* fetch the user’s scenarios once */
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
@ -156,15 +180,14 @@ async function sendPrompt() {
|
||||
});
|
||||
|
||||
/* ③ POST to the retirement endpoint */
|
||||
const res = await authFetch('/api/premium/retirement/aichat', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({
|
||||
prompt,
|
||||
scenario_id : currentScenario?.id, // ← keep it minimal
|
||||
chatHistory : messagesToSend // ← backend needs this to find userMsg
|
||||
})
|
||||
});
|
||||
const res = await authFetch(`/api/premium/retire/chat/threads/${threadId}/messages`, {
|
||||
method:'POST',
|
||||
headers:{ 'Content-Type':'application/json' },
|
||||
body: JSON.stringify({
|
||||
content: prompt,
|
||||
context: { scenario_id: currentScenario.id } // minimal — your backend uses it
|
||||
})
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
const assistantReply = data.reply || '(no response)';
|
||||
|
@ -1,7 +1,7 @@
|
||||
import React, { useRef, useState, useEffect, useContext } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { ProfileCtx } from '../App.js';
|
||||
import { setToken } from '../auth/authMemory.js';
|
||||
import { ProfileCtx } from '../App.js';
|
||||
import * as safeLocal from '../utils/safeLocal.js';
|
||||
|
||||
function SignIn({ setIsAuthenticated, setUser }) {
|
||||
const navigate = useNavigate();
|
||||
@ -13,7 +13,6 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the URL query param has ?session=expired
|
||||
const query = new URLSearchParams(location.search);
|
||||
if (query.get('session') === 'expired') {
|
||||
setShowSessionExpiredMsg(true);
|
||||
@ -21,74 +20,63 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
}, [location.search]);
|
||||
|
||||
const handleSignIn = async (event) => {
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
event.preventDefault();
|
||||
setError('');
|
||||
|
||||
// 0️⃣ clear everything that belongs to the *previous* user
|
||||
localStorage.removeItem('careerSuggestionsCache');
|
||||
localStorage.removeItem('lastSelectedCareerProfileId');
|
||||
localStorage.removeItem('aiClickCount');
|
||||
localStorage.removeItem('aiClickDate');
|
||||
localStorage.removeItem('aiRecommendations');
|
||||
localStorage.removeItem('premiumOnboardingState');
|
||||
localStorage.removeItem('financialProfile'); // if you cache it
|
||||
localStorage.removeItem('selectedScenario');
|
||||
// Clean slate from any prior user/session (keeps your allowlist style)
|
||||
safeLocal.clearMany([
|
||||
'id',
|
||||
'careerSuggestionsCache',
|
||||
'lastSelectedCareerProfileId',
|
||||
'aiClickCount',
|
||||
'aiClickDate',
|
||||
'aiRecommendations',
|
||||
'premiumOnboardingState',
|
||||
'financialProfile',
|
||||
'selectedScenario',
|
||||
]);
|
||||
|
||||
const username = usernameRef.current.value;
|
||||
const password = passwordRef.current.value;
|
||||
const username = usernameRef.current.value;
|
||||
const password = passwordRef.current.value;
|
||||
if (!username || !password) {
|
||||
setError('Please enter both username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!username || !password) {
|
||||
setError('Please enter both username and password');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Server should set an HttpOnly, SameSite cookie here
|
||||
const resp = await fetch('/api/signin', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // <-- important for cookie-based auth
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/signin', {
|
||||
method : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body : JSON.stringify({username, password}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
|
||||
|
||||
const data = await resp.json(); // ← read ONCE
|
||||
// Optional: keep allowlisted id if provided by API
|
||||
if (data.id) localStorage.setItem('id', data.id);
|
||||
|
||||
if (!resp.ok) throw new Error(data.error || 'Failed to sign in');
|
||||
// Load user profile for app state; cookie is sent automatically
|
||||
const profileRes = await fetch('/api/user-profile', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!profileRes.ok) throw new Error('Failed to load profile');
|
||||
const profile = await profileRes.json();
|
||||
|
||||
/* ---------------- success path ---------------- */
|
||||
const { token, id, user } = data;
|
||||
setFinancialProfile(profile);
|
||||
setScenario(null);
|
||||
setIsAuthenticated(true);
|
||||
setUser(data.user || null);
|
||||
|
||||
// fetch current user profile immediately
|
||||
const profileRes = await fetch('/api/user-profile', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
const profile = await profileRes.json();
|
||||
setFinancialProfile(profile);
|
||||
setScenario(null); // or fetch latest scenario separately
|
||||
|
||||
/* purge any leftovers from prior session */
|
||||
['careerSuggestionsCache',
|
||||
'lastSelectedCareerProfileId',
|
||||
'aiClickCount',
|
||||
'aiClickDate',
|
||||
'aiRecommendations',
|
||||
'premiumOnboardingState',
|
||||
'financialProfile',
|
||||
'selectedScenario'
|
||||
].forEach(k => localStorage.removeItem(k));
|
||||
|
||||
/* store new session data */
|
||||
localStorage.setItem('token', token);
|
||||
localStorage.setItem('id', id);
|
||||
|
||||
setIsAuthenticated(true);
|
||||
setUser(user);
|
||||
navigate('/signin-landing');
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
};
|
||||
navigate('/signin-landing');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Sign-in failed');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-100 p-4">
|
||||
{showSessionExpiredMsg && (
|
||||
<div className="mb-4 p-2 bg-red-100 border border-red-300 text-red-700 rounded">
|
||||
@ -110,12 +98,14 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
placeholder="Username"
|
||||
ref={usernameRef}
|
||||
className="w-full rounded border border-gray-300 p-2 focus:border-blue-500 focus:outline-none"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
ref={passwordRef}
|
||||
className="w-full rounded border border-gray-300 p-2 focus:border-blue-500 focus:outline-none"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
@ -141,7 +131,7 @@ function SignIn({ setIsAuthenticated, setUser }) {
|
||||
Forgot your password?
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -4,59 +4,81 @@ import ChangePasswordForm from './ChangePasswordForm.js';
|
||||
|
||||
function UserProfile() {
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [zipCode, setZipCode] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [zipCode, setZipCode] = useState('');
|
||||
const [selectedState, setSelectedState] = useState('');
|
||||
const [areas, setAreas] = useState([]);
|
||||
const [selectedArea, setSelectedArea] = useState('');
|
||||
const [careerSituation, setCareerSituation] = useState('');
|
||||
const [loadingAreas, setLoadingAreas] = useState(false);
|
||||
const [isPremiumUser, setIsPremiumUser] = useState(false);
|
||||
|
||||
const [phoneE164, setPhoneE164] = useState('');
|
||||
const [smsOptIn, setSmsOptIn] = useState(false);
|
||||
const [smsOptIn, setSmsOptIn] = useState(false);
|
||||
const [showChangePw, setShowChangePw] = useState(false);
|
||||
|
||||
// Subscription state
|
||||
const [sub, setSub] = useState(null);
|
||||
const isPremium = !!(sub?.is_premium || sub?.is_pro_premium);
|
||||
const planLabel = sub?.is_pro_premium ? 'Pro Premium' : sub?.is_premium ? 'Premium' : 'Free';
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Helper to do authorized fetch
|
||||
// Cookie-auth helper: include credentials; if unauthorized, bounce to sign-in
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
navigate('/signin');
|
||||
return null;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
credentials: 'include',
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
Accept: 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if ([401, 403].includes(res.status)) {
|
||||
console.warn('Token invalid or expired. Redirecting to Sign In.');
|
||||
navigate('/signin');
|
||||
if (res.status === 401 || res.status === 419) {
|
||||
navigate('/signin?session=expired');
|
||||
return null;
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
// --- Subscription helpers ---
|
||||
const loadSubStatus = async () => {
|
||||
const r = await authFetch('/api/premium/subscription/status', { method: 'GET' });
|
||||
if (!r || !r.ok) { setSub({ is_premium: 0, is_pro_premium: 0 }); return; }
|
||||
setSub(await r.json());
|
||||
};
|
||||
|
||||
const openPortal = async () => {
|
||||
const returnUrl = `${window.location.origin}/user-profile?portal=done`;
|
||||
const r = await authFetch(
|
||||
`/api/premium/stripe/customer-portal?return_url=${encodeURIComponent(returnUrl)}`
|
||||
);
|
||||
if (!r || !r.ok) {
|
||||
try { console.error('Portal error', r && (await r.text())); } catch {}
|
||||
return;
|
||||
}
|
||||
const { url } = await r.json();
|
||||
window.location.href = url;
|
||||
};
|
||||
|
||||
useEffect(() => { loadSubStatus(); }, []);
|
||||
|
||||
// When returning from Stripe portal, refresh status and clean the query param
|
||||
useEffect(() => {
|
||||
const fetchProfileAndAreas = async () => {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.get('portal') === 'done') {
|
||||
loadSubStatus();
|
||||
url.searchParams.delete('portal');
|
||||
window.history.replaceState({}, '', url.toString());
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load profile and prefetch areas
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const res = await authFetch('/api/user-profile', {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
const res = await authFetch('/api/user-profile', { method: 'GET' });
|
||||
if (!res || !res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
setFirstName(data.firstname || '');
|
||||
@ -69,20 +91,13 @@ function UserProfile() {
|
||||
setPhoneE164(data.phone_e164 || '');
|
||||
setSmsOptIn(!!data.sms_opt_in);
|
||||
|
||||
if (data.is_premium === 1) {
|
||||
setIsPremiumUser(true);
|
||||
}
|
||||
|
||||
// If we have a state, load its areas
|
||||
if (data.state) {
|
||||
setLoadingAreas(true);
|
||||
try {
|
||||
const areaRes = await authFetch(`/api/areas?state=${data.state}`);
|
||||
if (!areaRes || !areaRes.ok) {
|
||||
throw new Error('Failed to fetch areas');
|
||||
}
|
||||
const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(data.state)}`);
|
||||
if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas');
|
||||
const areaData = await areaRes.json();
|
||||
setAreas(areaData.areas);
|
||||
setAreas(areaData.areas || []);
|
||||
} catch (areaErr) {
|
||||
console.error('Error fetching areas:', areaErr);
|
||||
setAreas([]);
|
||||
@ -93,34 +108,18 @@ function UserProfile() {
|
||||
} catch (error) {
|
||||
console.error('Error loading user profile:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchProfileAndAreas();
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // only runs once
|
||||
}, []);
|
||||
|
||||
// Whenever user changes "selectedState", re-fetch areas
|
||||
// Refetch areas when state changes
|
||||
useEffect(() => {
|
||||
const fetchAreasByState = async () => {
|
||||
if (!selectedState) {
|
||||
setAreas([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
if (!selectedState) { setAreas([]); return; }
|
||||
setLoadingAreas(true);
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const areaRes = await fetch(`/api/areas?state=${selectedState}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!areaRes.ok) {
|
||||
throw new Error('Failed to fetch areas');
|
||||
}
|
||||
|
||||
const areaRes = await authFetch(`/api/areas?state=${encodeURIComponent(selectedState)}`);
|
||||
if (!areaRes || !areaRes.ok) throw new Error('Failed to fetch areas');
|
||||
const areaData = await areaRes.json();
|
||||
setAreas(areaData.areas || []);
|
||||
} catch (error) {
|
||||
@ -129,14 +128,12 @@ function UserProfile() {
|
||||
} finally {
|
||||
setLoadingAreas(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAreasByState();
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedState]);
|
||||
|
||||
const handleFormSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const profileData = {
|
||||
firstName,
|
||||
lastName,
|
||||
@ -146,25 +143,22 @@ function UserProfile() {
|
||||
area: selectedArea,
|
||||
careerSituation,
|
||||
phone_e164: phoneE164 || null,
|
||||
sms_opt_in: !!smsOptIn
|
||||
sms_opt_in: !!smsOptIn,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await authFetch('/api/user-profile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(profileData),
|
||||
});
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error('Failed to save profile');
|
||||
}
|
||||
if (!response || !response.ok) throw new Error('Failed to save profile');
|
||||
console.log('Profile saved successfully');
|
||||
} catch (error) {
|
||||
console.error('Error saving profile:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// FULL list of states for your dropdown
|
||||
const states = [
|
||||
{ name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' },
|
||||
{ name: 'Arkansas', code: 'AR' }, { name: 'California', code: 'CA' }, { name: 'Colorado', code: 'CO' },
|
||||
@ -185,24 +179,11 @@ function UserProfile() {
|
||||
{ name: 'West Virginia', code: 'WV' }, { name: 'Wisconsin', code: 'WI' }, { name: 'Wyoming', code: 'WY' },
|
||||
];
|
||||
|
||||
// The updated career situations (same as in SignUp.js)
|
||||
const careerSituations = [
|
||||
{
|
||||
id: 'planning',
|
||||
title: 'Planning Your Career',
|
||||
},
|
||||
{
|
||||
id: 'preparing',
|
||||
title: 'Preparing for Your (Next) Career',
|
||||
},
|
||||
{
|
||||
id: 'enhancing',
|
||||
title: 'Enhancing Your Career',
|
||||
},
|
||||
{
|
||||
id: 'retirement',
|
||||
title: 'Retirement Planning',
|
||||
},
|
||||
{ id: 'planning', title: 'Planning Your Career' },
|
||||
{ id: 'preparing', title: 'Preparing for Your (Next) Career' },
|
||||
{ id: 'enhancing', title: 'Enhancing Your Career' },
|
||||
{ id: 'retirement', title: 'Retirement Planning' },
|
||||
];
|
||||
|
||||
return (
|
||||
@ -213,9 +194,7 @@ function UserProfile() {
|
||||
<form onSubmit={handleFormSubmit} className="space-y-4">
|
||||
{/* First Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
First Name:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">First Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={firstName}
|
||||
@ -227,9 +206,7 @@ function UserProfile() {
|
||||
|
||||
{/* Last Name */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Last Name:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Last Name:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={lastName}
|
||||
@ -241,9 +218,7 @@ function UserProfile() {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Email:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Email:</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
@ -253,11 +228,9 @@ function UserProfile() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ZIP Code */}
|
||||
{/* ZIP */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
ZIP Code:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">ZIP Code:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={zipCode}
|
||||
@ -267,11 +240,9 @@ function UserProfile() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* State Dropdown */}
|
||||
{/* State */}
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
State:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">State:</label>
|
||||
<select
|
||||
value={selectedState}
|
||||
onChange={(e) => setSelectedState(e.target.value)}
|
||||
@ -280,24 +251,17 @@ function UserProfile() {
|
||||
>
|
||||
<option value="">Select a State</option>
|
||||
{states.map((s) => (
|
||||
<option key={s.code} value={s.code}>
|
||||
{s.name}
|
||||
</option>
|
||||
<option key={s.code} value={s.code}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Loading indicator for areas */}
|
||||
{loadingAreas && (
|
||||
<p className="text-sm text-gray-500">Loading areas...</p>
|
||||
)}
|
||||
{/* Areas */}
|
||||
{loadingAreas && <p className="text-sm text-gray-500">Loading areas...</p>}
|
||||
|
||||
{/* Areas Dropdown */}
|
||||
{!loadingAreas && areas.length > 0 && (
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Area:
|
||||
</label>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Area:</label>
|
||||
<select
|
||||
value={selectedArea}
|
||||
onChange={(e) => setSelectedArea(e.target.value)}
|
||||
@ -305,15 +269,14 @@ function UserProfile() {
|
||||
className="w-full rounded border border-gray-300 px-3 py-2 text-sm focus:border-blue-600 focus:outline-none"
|
||||
>
|
||||
<option value="">Select an Area</option>
|
||||
{areas.map((area, index) => (
|
||||
<option key={index} value={area}>
|
||||
{area}
|
||||
</option>
|
||||
{areas.map((area, idx) => (
|
||||
<option key={idx} value={area}>{area}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phone + SMS */}
|
||||
<div className="mt-4">
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">Mobile (E.164)</label>
|
||||
<input
|
||||
@ -345,30 +308,71 @@ function UserProfile() {
|
||||
>
|
||||
<option value="">Select One</option>
|
||||
{careerSituations.map((cs) => (
|
||||
<option key={cs.id} value={cs.id}>
|
||||
{cs.title}
|
||||
</option>
|
||||
<option key={cs.id} value={cs.id}>{cs.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChangePw(s => !s)}
|
||||
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
|
||||
>
|
||||
{showChangePw ? 'Cancel password change' : 'Change password'}
|
||||
</button>
|
||||
{/* Password */}
|
||||
<div className="mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowChangePw((s) => !s)}
|
||||
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
|
||||
>
|
||||
{showChangePw ? 'Cancel password change' : 'Change password'}
|
||||
</button>
|
||||
|
||||
{showChangePw && (
|
||||
<div className="mt-4">
|
||||
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Form Buttons */}
|
||||
{showChangePw && (
|
||||
<div className="mt-4">
|
||||
<ChangePasswordForm onPwdSuccess={() => setShowChangePw(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subscription */}
|
||||
<div className="mt-6 rounded border p-4">
|
||||
<h3 className="mb-2 text-lg font-semibold">Subscription</h3>
|
||||
{sub === null ? (
|
||||
<p className="text-sm text-gray-500">Loading subscription…</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm">
|
||||
Current plan: <strong>{planLabel}</strong>
|
||||
{sub?.cancel_at_period_end && (
|
||||
<span className="ml-2 text-amber-700">
|
||||
(Scheduled to end {new Date(sub.current_period_end).toLocaleDateString()})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openPortal}
|
||||
className="rounded bg-blue-600 px-3 py-2 text-white hover:bg-blue-700"
|
||||
>
|
||||
Manage subscription
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={loadSubStatus}
|
||||
className="rounded border px-3 py-2 text-sm hover:bg-gray-100"
|
||||
>
|
||||
Refresh status
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!isPremium && (
|
||||
<p className="mt-2 text-sm text-gray-600">
|
||||
Premium features are disabled. Re-subscribe anytime; your data is preserved.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-6 flex items-center justify-end space-x-3">
|
||||
<button
|
||||
type="submit"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import api from '../auth/apiClient.js';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const careersFile = '../../updated_career_data_final.json';
|
||||
@ -22,7 +22,7 @@ const mapScoreToScale = (score) => {
|
||||
// Fully corrected function to fetch ratings from O*Net API
|
||||
const fetchCareerRatingsCorrected = async (socCode) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
const response = await api.get(
|
||||
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
|
||||
{ auth: { username: onetUsername, password: onetPassword } }
|
||||
);
|
||||
|
@ -2,15 +2,17 @@ import axios from 'axios';
|
||||
|
||||
export async function clientGeocodeZip(zip) {
|
||||
const apiKey = process.env.REACT_APP_GOOGLE_MAPS_API_KEY;
|
||||
const url = `https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${apiKey}`;
|
||||
if (!apiKey) throw new Error('REACT_APP_GOOGLE_MAPS_API_KEY is not set at build time.');
|
||||
|
||||
const resp = await axios.get(url);
|
||||
if (resp.data.status === 'OK' && resp.data.results && resp.data.results.length > 0) {
|
||||
const url =
|
||||
`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(zip)}&key=${encodeURIComponent(apiKey)}`;
|
||||
|
||||
const resp = await axios.get(url, { withCredentials: false });
|
||||
if (resp.data.status === 'OK' && resp.data.results?.length > 0) {
|
||||
return resp.data.results[0].geometry.location; // { lat, lng }
|
||||
}
|
||||
throw new Error('Geocoding failed.');
|
||||
throw new Error(`Geocoding failed: ${resp.data.status || 'Unknown'}.`);
|
||||
}
|
||||
|
||||
// utils/apiUtils.js
|
||||
|
||||
export function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
|
@ -1,3 +1,8 @@
|
||||
// Cookie-based auth fetch used across the app.
|
||||
// - Does NOT read from localStorage.
|
||||
// - Sends cookies automatically (credentials: 'include').
|
||||
// - Keeps the same behavior: return Response, or null on 401/403.
|
||||
|
||||
let onSessionExpiredCallback = null;
|
||||
|
||||
export const setSessionExpiredCallback = (callback) => {
|
||||
@ -5,26 +10,21 @@ export const setSessionExpiredCallback = (callback) => {
|
||||
};
|
||||
|
||||
const authFetch = async (url, options = {}) => {
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
if (!token) {
|
||||
onSessionExpiredCallback?.();
|
||||
return null;
|
||||
}
|
||||
|
||||
const method = options.method?.toUpperCase() || 'GET';
|
||||
const shouldIncludeContentType = ['POST', 'PUT', 'PATCH'].includes(method);
|
||||
const method = (options.method || 'GET').toUpperCase();
|
||||
const hasCTHeader = options.headers && Object.prototype.hasOwnProperty.call(options.headers, 'Content-Type');
|
||||
const shouldIncludeContentType = ['POST','PUT','PATCH'].includes(method) && !hasCTHeader;
|
||||
|
||||
const res = await fetch(url, {
|
||||
credentials: 'include', // <-- send httpOnly session cookie
|
||||
...options,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(shouldIncludeContentType && { 'Content-Type': 'application/json' }),
|
||||
...options.headers,
|
||||
...(shouldIncludeContentType ? { 'Content-Type': 'application/json' } : {}),
|
||||
Accept: 'application/json',
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
if ([401, 403].includes(res.status)) {
|
||||
if (res.status === 401 || res.status === 403) {
|
||||
onSessionExpiredCallback?.();
|
||||
return null;
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import api from '../auth/apiClient.js';
|
||||
|
||||
// ============= handleCareerClick =============
|
||||
const handleCareerClick = useCallback(
|
||||
async (career) => {
|
||||
@ -34,7 +36,7 @@
|
||||
// Salary
|
||||
let salaryResponse;
|
||||
try {
|
||||
salaryResponse = await axios.get('/api/salary', {
|
||||
salaryResponse = await api.get('/api/salary', {
|
||||
params: { socCode: socCode.split('.')[0], area: areaTitle },
|
||||
});
|
||||
} catch (error) {
|
||||
@ -46,7 +48,7 @@
|
||||
// Economic
|
||||
let economicResponse;
|
||||
try {
|
||||
economicResponse = await axios.get(`api/projections/${socCode.split('.')[0]}`, {
|
||||
economicResponse = await api.get(`api/projections/${socCode.split('.')[0]}`, {
|
||||
params: { state: fullName }, // e.g. "Kentucky"
|
||||
});
|
||||
} catch (error) {
|
||||
@ -56,7 +58,7 @@
|
||||
// Tuition
|
||||
let tuitionResponse;
|
||||
try {
|
||||
tuitionResponse = await axios.get('/api/tuition', {
|
||||
tuitionResponse = await api.get('/api/tuition', {
|
||||
params: { cipCode: cleanedCipCode, state: userState },
|
||||
});
|
||||
} catch (error) {
|
||||
|
30
src/utils/onboardingDraftApi.js
Normal file
30
src/utils/onboardingDraftApi.js
Normal file
@ -0,0 +1,30 @@
|
||||
// src/utils/onboardingDraftApi.js
|
||||
import authFetch from './authFetch.js';
|
||||
|
||||
const API_ROOT = (import.meta?.env?.VITE_API_BASE || '').replace(/\/+$/, '');
|
||||
const DRAFT_URL = `${API_ROOT}/api/premium/onboarding/draft`;
|
||||
|
||||
export async function loadDraft() {
|
||||
const res = await authFetch(DRAFT_URL);
|
||||
if (!res) return null; // session expired
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) throw new Error(`loadDraft ${res.status}`);
|
||||
return res.json(); // null or { id, step, data }
|
||||
}
|
||||
|
||||
export async function saveDraft({ id = null, step = 0, data = {} } = {}) {
|
||||
const res = await authFetch(DRAFT_URL, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id, step, data }),
|
||||
});
|
||||
if (!res) return null;
|
||||
if (!res.ok) throw new Error(`saveDraft ${res.status}`);
|
||||
return res.json(); // { id, step }
|
||||
}
|
||||
|
||||
export async function clearDraft() {
|
||||
const res = await authFetch(DRAFT_URL, { method: 'DELETE' });
|
||||
if (!res) return false;
|
||||
if (!res.ok) throw new Error(`clearDraft ${res.status}`);
|
||||
return true; // server returns { ok: true }
|
||||
}
|
9
src/utils/onboardingGuard.js
Normal file
9
src/utils/onboardingGuard.js
Normal file
@ -0,0 +1,9 @@
|
||||
// src/utils/onboardingGuard.js
|
||||
export function isOnboardingInProgress() {
|
||||
try {
|
||||
const ptr = JSON.parse(localStorage.getItem('premiumOnboardingPointer') || '{}');
|
||||
if (!Number.isInteger(ptr.step)) return false;
|
||||
const lastStepIndex = ptr.skipFin ? 3 : 4; // Welcome, Career, (Fin), College, Review
|
||||
return ptr.step < lastStepIndex;
|
||||
} catch { return false; }
|
||||
}
|
@ -1,21 +1,65 @@
|
||||
// safeLocal.js
|
||||
// src/utils/safeLocal.js
|
||||
const NS = 'aptiva:';
|
||||
|
||||
// Whitelist + TTL (ms)
|
||||
const ALLOW = {
|
||||
theme: 30*24*3600*1000, // 30d
|
||||
layoutMode: 30*24*3600*1000, // 30d
|
||||
lastPanel: 24*3600*1000, // 1d
|
||||
flagsVersion: 6*3600*1000, // 6h
|
||||
lastCareerName: 24*3600*1000, // 1d
|
||||
theme: 30 * 24 * 3600 * 1000, // 30d
|
||||
layoutMode: 30 * 24 * 3600 * 1000, // 30d
|
||||
lastPanel: 1 * 24 * 3600 * 1000, // 1d
|
||||
flagsVersion: 6 * 3600 * 1000, // 6h
|
||||
lastCareerName: 1 * 24 * 3600 * 1000, // 1d
|
||||
};
|
||||
|
||||
function _ns(k) { return `${NS}${k}`; }
|
||||
|
||||
// Strict setter: only allow whitelisted keys
|
||||
export function setItem(key, value) {
|
||||
if (!(key in ALLOW)) throw new Error(`[safeLocal] Not allowed: ${key}`);
|
||||
const exp = Date.now() + (ALLOW[key] || 0);
|
||||
localStorage.setItem(NS + key, JSON.stringify({ v: value, e: exp }));
|
||||
try {
|
||||
localStorage.setItem(_ns(key), JSON.stringify({ v: value, e: exp }));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Getter: enforces TTL; returns null if missing/expired/not allowed
|
||||
export function getItem(key) {
|
||||
if (!(key in ALLOW)) return null;
|
||||
const raw = localStorage.getItem(NS + key); if (!raw) return null;
|
||||
try { const { v, e } = JSON.parse(raw); if (e && e < Date.now()) { localStorage.removeItem(NS + key); return null; } return v; }
|
||||
catch { localStorage.removeItem(NS + key); return null; }
|
||||
try {
|
||||
const raw = localStorage.getItem(_ns(key));
|
||||
if (!raw) return null;
|
||||
const { v, e } = JSON.parse(raw);
|
||||
if (e && e < Date.now()) {
|
||||
localStorage.removeItem(_ns(key));
|
||||
return null;
|
||||
}
|
||||
return v;
|
||||
} catch {
|
||||
try { localStorage.removeItem(_ns(key)); } catch {}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export function removeItem(key) { localStorage.removeItem(NS + key); }
|
||||
|
||||
// Remove one key (tries namespaced; also best-effort raw for legacy cleanup)
|
||||
export function removeItem(key) {
|
||||
try { localStorage.removeItem(_ns(key)); } catch {}
|
||||
try { localStorage.removeItem(key); } catch {}
|
||||
}
|
||||
|
||||
// Remove many keys (safe to pass unknown/legacy keys)
|
||||
export function clearMany(keys = []) {
|
||||
for (const k of keys) {
|
||||
try { localStorage.removeItem(_ns(k)); } catch {}
|
||||
try { localStorage.removeItem(k); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Optional helper to purge all whitelisted keys at once
|
||||
export function clearAllAllowed() {
|
||||
for (const k of Object.keys(ALLOW)) {
|
||||
try { localStorage.removeItem(_ns(k)); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Default export (so `import safeLocal from ...` works)
|
||||
const safeLocal = { setItem, getItem, removeItem, clearMany, clearAllAllowed };
|
||||
export default safeLocal;
|
||||
|
@ -1,23 +1,52 @@
|
||||
// storageGuard.js
|
||||
const RESTRICTED_SUBSTRINGS = [
|
||||
'token','access','refresh','userid','user_id','user','profile','email','phone',
|
||||
'answers','interest','riasec','salary','ssn','auth'
|
||||
];
|
||||
function shouldBlock(key) {
|
||||
const k = String(key || '').toLowerCase();
|
||||
return RESTRICTED_SUBSTRINGS.some(s => k.includes(s));
|
||||
// src/utils/storageGuard.js
|
||||
// Blocks obviously sensitive keys from being written to Web Storage.
|
||||
// Shows a loud console error so you catch any accidental writes.
|
||||
|
||||
const SENSITIVE_KEYS = new Set([
|
||||
'token',
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'aptiva_access',
|
||||
'aptiva_access_token',
|
||||
'idToken',
|
||||
'id_token',
|
||||
'authorization',
|
||||
'financialProfile',
|
||||
'premiumOnboardingState',
|
||||
]);
|
||||
|
||||
function looksLikeJwt(str = '') {
|
||||
// quick-and-safe: three dot-separated base64-ish segments
|
||||
return typeof str === 'string' && str.split('.').length === 3 && str.length > 24;
|
||||
}
|
||||
function wrap(storage) {
|
||||
if (!storage) return;
|
||||
const _set = storage.setItem.bind(storage);
|
||||
storage.setItem = (k, v) => {
|
||||
if (shouldBlock(k)) {
|
||||
throw new Error(`[storageGuard] Blocked setItem(\"${k}\"). Sensitive data is not allowed in Web Storage.`);
|
||||
|
||||
export function installStorageGuard({ noisy = true } = {}) {
|
||||
if (typeof window === 'undefined' || !window.localStorage) return () => {};
|
||||
|
||||
const origSetItem = Storage.prototype.setItem;
|
||||
|
||||
Storage.prototype.setItem = function guardedSetItem(key, value) {
|
||||
try {
|
||||
const k = String(key || '');
|
||||
const v = String(value || '');
|
||||
|
||||
if (SENSITIVE_KEYS.has(k) || looksLikeJwt(v) || /bearer\s+/i.test(v)) {
|
||||
if (noisy) {
|
||||
console.error(
|
||||
`[storageGuard] Blocked setItem("${k}"). ` +
|
||||
`Sensitive data is not allowed in Web Storage.`
|
||||
);
|
||||
}
|
||||
return; // block write
|
||||
}
|
||||
} catch {
|
||||
// fall through to original on any unexpected error
|
||||
}
|
||||
return _set(k, v);
|
||||
return origSetItem.apply(this, arguments);
|
||||
};
|
||||
|
||||
// return an uninstall function in case you ever want to restore it
|
||||
return () => {
|
||||
Storage.prototype.setItem = origSetItem;
|
||||
};
|
||||
}
|
||||
export function installStorageGuard() {
|
||||
try { wrap(window.localStorage); } catch {}
|
||||
try { wrap(window.sessionStorage); } catch {}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import api from '../auth/apiClient.js';;
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
const testOutputFile = '../../test_careers_with_ratings.json';
|
||||
@ -21,7 +21,7 @@ const mapScoreToScale = (score) => {
|
||||
|
||||
const fetchCareerRatingsTest = async (socCode) => {
|
||||
try {
|
||||
const response = await axios.get(
|
||||
const response = await api.get(
|
||||
`https://services.onetcenter.org/ws/online/occupations/${socCode}/details`,
|
||||
{ auth: { username: onetUsername, password: onetPassword } }
|
||||
);
|
||||
|
@ -1,25 +0,0 @@
|
||||
import axios from 'axios';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
// Load environment variables from .env file
|
||||
dotenv.config();
|
||||
|
||||
// Base64 encode username and password for Basic Authentication
|
||||
const authToken = Buffer.from(`${process.env.ONET_USERNAME}:${process.env.ONET_PASSWORD}`).toString('base64');
|
||||
|
||||
// Define the API endpoint to test
|
||||
const url = 'https://services.onetcenter.org/ws/mnm/interestprofiler/questions?start=1&end=5';
|
||||
|
||||
// Send a GET request with Authorization header
|
||||
axios.get(url, {
|
||||
headers: {
|
||||
'Authorization': `Basic ${authToken}`,
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
console.log('API Response:', response.data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error.response ? error.response.data : error.message);
|
||||
});
|
Loading…
Reference in New Issue
Block a user