From c0a68eb81c862377d3feca6a3e699148bd293aee Mon Sep 17 00:00:00 2001
From: Josh
Date: Thu, 30 Oct 2025 10:28:38 +0000
Subject: [PATCH] Big one - admin portal and DOB COPPA compliance
---
.build.hash | 2 +-
.dockerignore | 14 +
.gitignore | 13 +
.woodpecker.yml | 12 +-
Dockerfile.server4 | 24 +
backend/server1.js | 652 +++-
backend/server2.js | 21 +-
backend/server3.js | 20 +-
backend/server4.js | 3129 +++++++++++++++++
backend/shared/db/withEncryption.js | 3 +
deploy_all.sh | 6 +-
docker-compose.yml | 46 +-
migrate_encrypted_columns.sql | 281 --
nginx.conf | 79 +-
src/App.js | 180 +-
src/components/Admin/AddStudent.js | 155 +
src/components/Admin/AdminLayout.js | 130 +
src/components/Admin/AdminLogin.js | 102 +
src/components/Admin/BouncedInvitations.js | 244 ++
src/components/Admin/Dashboard.js | 538 +++
src/components/Admin/PendingInvitations.js | 188 +
src/components/Admin/RosterUpload.js | 438 +++
src/components/Admin/Settings.js | 945 +++++
src/components/Admin/StudentDetail.js | 576 +++
src/components/Admin/StudentList.js | 402 +++
src/components/CareerExplorer.js | 8 +
src/components/CareerProfileForm.js | 3 +-
src/components/CareerRoadmap.js | 6 +-
src/components/InterestInventory.js | 2 +-
src/components/InviteResponse.js | 235 ++
src/components/InviteSignup.js | 93 +
src/components/LinkSecondaryEmail.js | 227 ++
.../PremiumOnboarding/CareerOnboarding.js | 1 +
.../PremiumOnboarding/OnboardingContainer.js | 14 +-
src/components/PrivacyPolicy.js | 71 +-
src/components/PrivacySettings.js | 238 ++
src/components/PrivacySettingsModal.js | 237 ++
src/components/RiaSecChart.js | 35 +-
src/components/SignIn.js | 11 +-
src/components/SignUp.js | 183 +-
src/components/TermsOfService.js | 5 +-
src/components/UserProfile.js | 20 +
src/contexts/AdminContext.js | 97 +
43 files changed, 9306 insertions(+), 380 deletions(-)
create mode 100644 Dockerfile.server4
create mode 100644 backend/server4.js
delete mode 100644 migrate_encrypted_columns.sql
create mode 100644 src/components/Admin/AddStudent.js
create mode 100644 src/components/Admin/AdminLayout.js
create mode 100644 src/components/Admin/AdminLogin.js
create mode 100644 src/components/Admin/BouncedInvitations.js
create mode 100644 src/components/Admin/Dashboard.js
create mode 100644 src/components/Admin/PendingInvitations.js
create mode 100644 src/components/Admin/RosterUpload.js
create mode 100644 src/components/Admin/Settings.js
create mode 100644 src/components/Admin/StudentDetail.js
create mode 100644 src/components/Admin/StudentList.js
create mode 100644 src/components/InviteResponse.js
create mode 100644 src/components/InviteSignup.js
create mode 100644 src/components/LinkSecondaryEmail.js
create mode 100644 src/components/PrivacySettings.js
create mode 100644 src/components/PrivacySettingsModal.js
create mode 100644 src/contexts/AdminContext.js
diff --git a/.build.hash b/.build.hash
index 0ce7a74..6cd88d5 100644
--- a/.build.hash
+++ b/.build.hash
@@ -1 +1 @@
-7525e7b74f06b3341cb73a157afaea13b4af1f5d-006fe5ea7fd8cb46290bcd0cf210f88f2ec04061-e9eccd451b778829eb2f2c9752c670b707e1268b
+7ed237a540a248342b77de971556a895ac91cacc-006fe5ea7fd8cb46290bcd0cf210f88f2ec04061-e9eccd451b778829eb2f2c9752c670b707e1268b
diff --git a/.dockerignore b/.dockerignore
index 1e8fb70..2d0f3bb 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -47,3 +47,17 @@ GAETC_PRINT_MATERIALS_FINAL.md
CONFERENCE_MATERIALS.md
APTIVA_AI_FEATURES_DOCUMENTATION.md
+# Admin Portal Design Documents (not needed in containers)
+ORG_ADMIN_PORTAL_DESIGN.md
+ADMIN_PORTAL_DEPLOYMENT.md
+SERVER4_SECURITY_REVIEW.md
+SERVER4_ACTUAL_SECURITY_PATTERNS.md
+
+# Security Analysis Documents (sensitive - never ship)
+.security-notes-*.md
+
+# Migration and SQL files (run manually, not needed in containers)
+*.sql
+**/*.sql
+migrations/
+
diff --git a/.gitignore b/.gitignore
index 63c9f93..fa9b669 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,16 @@ ACCURATE_COST_PROJECTIONS.md
GAETC_PRINT_MATERIALS_FINAL.md
CONFERENCE_MATERIALS.md
APTIVA_AI_FEATURES_DOCUMENTATION.md
+
+# Admin Portal Design Documents
+ORG_ADMIN_PORTAL_DESIGN.md
+ADMIN_PORTAL_DEPLOYMENT.md
+SERVER4_SECURITY_REVIEW.md
+SERVER4_ACTUAL_SECURITY_PATTERNS.md
+
+# Security Analysis Documents
+.security-notes-*.md
+
+# Migration and SQL files (run manually on database)
+*.sql
+migrations/
diff --git a/.woodpecker.yml b/.woodpecker.yml
index ae7e09c..afcc70a 100644
--- a/.woodpecker.yml
+++ b/.woodpecker.yml
@@ -24,7 +24,7 @@ steps:
# Check which images already exist in PROD (so we don't try to push them)
MISSING=""
- for s in server1 server2 server3 nginx; do
+ for s in server1 server2 server3 server4 nginx; do
REF="docker://$DST/$s:$IMG_TAG"
if ! skopeo inspect --creds "oauth2accesstoken:$TOKEN" "$REF" >/dev/null 2>&1; then
MISSING="$MISSING $s"
@@ -67,7 +67,7 @@ steps:
DST="us-central1-docker.pkg.dev/aptivaai-prod/aptiva-repo"
apt-get update -qq && apt-get install -y -qq skopeo
TOKEN="$(gcloud auth print-access-token)"
- for s in server1 server2 server3 nginx; do
+ for s in server1 server2 server3 server4 nginx; do
REF="docker://$DST/$s:$IMG_TAG"
echo "🔎 verify $REF"
skopeo inspect --creds "oauth2accesstoken:$TOKEN" "$REF" >/dev/null
@@ -93,7 +93,7 @@ steps:
export PATH="$PATH:$(pwd)/bin"
TOKEN="$(gcloud auth print-access-token)"
- for s in server1 server2 server3 nginx; do
+ for s in server1 server2 server3 server4 nginx; do
REF="$REG/$s:$IMG_TAG"
echo "🛡 scan $REF"
trivy image --username oauth2accesstoken --password "$TOKEN" \
@@ -170,6 +170,8 @@ steps:
SERVER1_PORT="$(gcloud secrets versions access latest --secret=SERVER1_PORT_$ENV --project="$PROJECT")"; export SERVER1_PORT
SERVER2_PORT="$(gcloud secrets versions access latest --secret=SERVER2_PORT_$ENV --project="$PROJECT")"; export SERVER2_PORT
SERVER3_PORT="$(gcloud secrets versions access latest --secret=SERVER3_PORT_$ENV --project="$PROJECT")"; export SERVER3_PORT
+ SERVER4_PORT="$(gcloud secrets versions access latest --secret=SERVER4_PORT_$ENV --project="$PROJECT")"; export SERVER4_PORT
+ ADMIN_PORTAL_URL="$(gcloud secrets versions access latest --secret=ADMIN_PORTAL_URL_$ENV --project="$PROJECT")"; export ADMIN_PORTAL_URL
ENV_NAME="$(gcloud secrets versions access latest --secret=ENV_NAME_$ENV --project="$PROJECT")"; export ENV_NAME
CORS_ALLOWED_ORIGINS="$(gcloud secrets versions access latest --secret=CORS_ALLOWED_ORIGINS_$ENV --project="$PROJECT")"; export CORS_ALLOWED_ORIGINS
APTIVA_API_BASE="$(gcloud secrets versions access latest --secret=APTIVA_API_BASE_$ENV --project="$PROJECT")"; export APTIVA_API_BASE
@@ -185,10 +187,10 @@ steps:
gcloud auth configure-docker us-central1-docker.pkg.dev -q
sudo gcloud auth configure-docker us-central1-docker.pkg.dev -q
- 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,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE,PROJECT,TOKEN_MAX_AGE_MS,COOKIE_SECURE,COOKIE_SAMESITE,ACCESS_COOKIE_NAME,EMAIL_INDEX_SECRET \
+ 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,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,SERVER4_PORT,ADMIN_PORTAL_URL,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE,PROJECT,TOKEN_MAX_AGE_MS,COOKIE_SECURE,COOKIE_SAMESITE,ACCESS_COOKIE_NAME,EMAIL_INDEX_SECRET \
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,GOOGLE_MAPS_API_KEY,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE,PROJECT,TOKEN_MAX_AGE_MS,COOKIE_SECURE,COOKIE_SAMESITE,ACCESS_COOKIE_NAME,EMAIL_INDEX_SECRET \
+ 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,SERVER1_PORT,SERVER2_PORT,SERVER3_PORT,SERVER4_PORT,ADMIN_PORTAL_URL,CORS_ALLOWED_ORIGINS,ENV_NAME,APTIVA_API_BASE,PROJECT,TOKEN_MAX_AGE_MS,COOKIE_SECURE,COOKIE_SAMESITE,ACCESS_COOKIE_NAME,EMAIL_INDEX_SECRET \
docker compose up -d --force-recreate --remove-orphans
echo "✅ Prod stack refreshed with tag $IMG_TAG"
diff --git a/Dockerfile.server4 b/Dockerfile.server4
new file mode 100644
index 0000000..5d7cd75
--- /dev/null
+++ b/Dockerfile.server4
@@ -0,0 +1,24 @@
+FROM node:20-bookworm-slim AS base
+
+RUN groupadd -r app && useradd -r -g app app
+WORKDIR /app
+
+# add curl for healthchecks (+ CA bundle)
+RUN apt-get update -y && \
+ apt-get install -y --no-install-recommends \
+ build-essential python3 pkg-config curl ca-certificates && \
+ rm -rf /var/lib/apt/lists/*
+
+COPY package*.json ./
+RUN npm ci --unsafe-perm --omit=dev
+
+# app payload (only what runtime needs)
+COPY --chown=app:app backend/ ./backend/
+COPY --chown=app:app src/ai/ ./src/ai/
+COPY --chown=app:app src/assets/ ./src/assets/
+COPY --chown=app:app backend/data/ ./backend/data/
+
+RUN mkdir -p /tmp && chmod 1777 /tmp
+USER app
+
+CMD ["node", "backend/server4.js"]
diff --git a/backend/server1.js b/backend/server1.js
index 9335554..56c15df 100755
--- a/backend/server1.js
+++ b/backend/server1.js
@@ -475,6 +475,16 @@ function emailLookup(email) {
.digest('hex');
}
+// ---- Username index helper (HMAC-SHA256 of normalized username) ----
+const USERNAME_INDEX_KEY = process.env.USERNAME_INDEX_SECRET || JWT_SECRET;
+
+function usernameLookup(username) {
+ return crypto
+ .createHmac('sha256', USERNAME_INDEX_KEY)
+ .update(String(username).trim().toLowerCase())
+ .digest('hex');
+}
+
// ----- Password reset config (zero-config dev mode) -----
if (!process.env.APTIVA_API_BASE) {
@@ -871,56 +881,387 @@ app.post('/api/auth/verify/phone/confirm', requireAuth, verifyConfirmLimiter, as
}
});
+/* ------------------------------------------------------------------
+ VALIDATE INVITATION TOKEN (for B2B student invitations)
+------------------------------------------------------------------ */
+app.post('/api/validate-invite', async (req, res) => {
+ const { token } = req.body;
+
+ if (!token) {
+ return res.status(400).json({ error: 'Invitation token required' });
+ }
+
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET);
+
+ if (decoded.prp !== 'student_invite') {
+ return res.status(400).json({ error: 'Invalid invitation token' });
+ }
+
+ // Verify user still exists and is pending
+ const [users] = await pool.query(
+ 'SELECT id, email, firstname, lastname, username FROM user_profile WHERE id = ? LIMIT 1',
+ [decoded.userId]
+ );
+
+ if (!users.length) {
+ return res.status(404).json({ error: 'Invitation not found or expired' });
+ }
+
+ const user = users[0];
+
+ // Check if already completed signup (username is not NULL)
+ if (user.username) {
+ return res.status(400).json({ error: 'This invitation has already been used. Please sign in instead.' });
+ }
+
+ // Verify enrollment exists and is pending
+ const [enrollment] = await pool.query(
+ 'SELECT enrollment_status FROM organization_students WHERE organization_id = ? AND user_id = ?',
+ [decoded.organizationId, decoded.userId]
+ );
+
+ if (!enrollment.length) {
+ return res.status(404).json({ error: 'Invitation not found' });
+ }
+
+ // Decrypt email and names for pre-fill
+ let email = user.email;
+ let firstname = user.firstname;
+ let lastname = user.lastname;
+
+ try {
+ email = decrypt(email);
+ } catch (err) {
+ // Not encrypted or decryption failed
+ }
+
+ try {
+ if (firstname && firstname.startsWith('gcm:')) {
+ firstname = decrypt(firstname);
+ }
+ } catch (err) {
+ // Not encrypted or decryption failed
+ }
+
+ try {
+ if (lastname && lastname.startsWith('gcm:')) {
+ lastname = decrypt(lastname);
+ }
+ } catch (err) {
+ // Not encrypted or decryption failed
+ }
+
+ return res.json({
+ valid: true,
+ email: email,
+ firstname: firstname,
+ lastname: lastname,
+ userId: decoded.userId,
+ organizationId: decoded.organizationId
+ });
+ } catch (err) {
+ if (err.name === 'TokenExpiredError') {
+ return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
+ }
+ console.error('[validate-invite] Error:', err.message);
+ return res.status(400).json({ error: 'Invalid invitation token' });
+ }
+});
+
+/* ------------------------------------------------------------------
+ LINK EXISTING ACCOUNT TO ORGANIZATION (for existing users)
+------------------------------------------------------------------ */
+app.post('/api/link-account', requireAuth, async (req, res) => {
+ const { token } = req.body;
+ const userId = req.userId; // From requireAuth middleware
+
+ if (!token) {
+ return res.status(400).json({ error: 'Invitation token required' });
+ }
+
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET);
+
+ if (decoded.prp !== 'student_invite') {
+ return res.status(400).json({ error: 'Invalid invitation token' });
+ }
+
+ if (decoded.isNewUser !== false) {
+ return res.status(400).json({ error: 'This invitation is for a new account, not account linking' });
+ }
+
+ // Verify the token's userId matches the logged-in user
+ if (decoded.userId !== userId) {
+ return res.status(400).json({ error: 'This invitation is for a different account' });
+ }
+
+ const organizationId = decoded.organizationId;
+
+ // Check if already enrolled
+ const [existing] = await pool.query(
+ 'SELECT id, enrollment_status, invitation_sent_at FROM organization_students WHERE organization_id = ? AND user_id = ? LIMIT 1',
+ [organizationId, userId]
+ );
+
+ if (existing.length > 0) {
+ // Already enrolled - update status to active and set invitation_sent_at if null
+ const invitationSentAt = existing[0].invitation_sent_at || new Date();
+ await pool.query(
+ 'UPDATE organization_students SET enrollment_status = ?, invitation_sent_at = ?, invitation_accepted_at = NOW(), updated_at = NOW() WHERE organization_id = ? AND user_id = ?',
+ ['active', invitationSentAt, organizationId, userId]
+ );
+ } else {
+ return res.status(404).json({ error: 'Enrollment record not found. Please contact your administrator.' });
+ }
+
+ // Ensure user has premium access (org students get premium)
+ await pool.query(
+ 'UPDATE user_profile SET is_premium = 1 WHERE id = ?',
+ [userId]
+ );
+
+ return res.json({
+ message: 'Account linked successfully',
+ organizationId
+ });
+ } catch (err) {
+ if (err.name === 'TokenExpiredError') {
+ return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
+ }
+ console.error('[link-account] Error:', err.message);
+ return res.status(400).json({ error: 'Invalid invitation token' });
+ }
+});
+
+/* ------------------------------------------------------------------
+ LINK SECONDARY EMAIL TO EXISTING ACCOUNT (for new user invitations with different email)
+------------------------------------------------------------------ */
+app.post('/api/link-secondary-email', requireAuth, async (req, res) => {
+ const { token } = req.body;
+ const loggedInUserId = req.userId; // From requireAuth middleware
+
+ if (!token) {
+ return res.status(400).json({ error: 'Invitation token required' });
+ }
+
+ try {
+ const decoded = jwt.verify(token, JWT_SECRET);
+
+ if (decoded.prp !== 'student_invite') {
+ return res.status(400).json({ error: 'Invalid invitation token' });
+ }
+
+ if (decoded.isNewUser !== true) {
+ return res.status(400).json({ error: 'This invitation is for an existing email, not secondary email linking' });
+ }
+
+ const shellUserId = decoded.userId; // The shell user_id created during roster upload
+ const invitationEmail = decoded.email;
+ const organizationId = decoded.organizationId;
+
+ // Get the shell user's encrypted email and firstname for user_emails
+ const [shellUser] = await pool.query(
+ 'SELECT email, firstname FROM user_profile WHERE id = ? LIMIT 1',
+ [shellUserId]
+ );
+
+ if (!shellUser || shellUser.length === 0) {
+ return res.status(404).json({ error: 'Invitation not found or expired' });
+ }
+
+ // Add the invitation email to user_emails as secondary email for the logged-in user
+ const emailNorm = String(invitationEmail).trim().toLowerCase();
+ const emailLookupVal = emailLookup(emailNorm);
+ const encEmail = encrypt(emailNorm);
+
+ await pool.query(
+ 'INSERT INTO user_emails (user_id, email, email_lookup, is_primary, is_verified, verified_at) VALUES (?, ?, ?, 0, 1, NOW())',
+ [loggedInUserId, encEmail, emailLookupVal]
+ );
+
+ // Get invitation_sent_at from the enrollment record
+ const [enrollment] = await pool.query(
+ 'SELECT invitation_sent_at FROM organization_students WHERE user_id = ? AND organization_id = ? LIMIT 1',
+ [shellUserId, organizationId]
+ );
+ const invitationSentAt = enrollment[0]?.invitation_sent_at || new Date();
+
+ // Update organization_students to point to the real user_id instead of shell
+ await pool.query(
+ 'UPDATE organization_students SET user_id = ?, enrollment_status = ?, invitation_sent_at = ?, invitation_accepted_at = NOW(), updated_at = NOW() WHERE user_id = ? AND organization_id = ?',
+ [loggedInUserId, 'active', invitationSentAt, shellUserId, organizationId]
+ );
+
+ // Ensure user has premium access (org students get premium)
+ await pool.query(
+ 'UPDATE user_profile SET is_premium = 1 WHERE id = ?',
+ [loggedInUserId]
+ );
+
+ // Delete the shell user_profile and any related records
+ await pool.query('DELETE FROM user_profile WHERE id = ?', [shellUserId]);
+
+ return res.json({
+ message: 'Secondary email linked successfully',
+ organizationId
+ });
+ } catch (err) {
+ if (err.name === 'TokenExpiredError') {
+ return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
+ }
+ if (err.code === 'ER_DUP_ENTRY') {
+ return res.status(409).json({ error: 'This email is already linked to an account.' });
+ }
+ console.error('[link-secondary-email] Error:', err.message);
+ return res.status(400).json({ error: 'Failed to link secondary email' });
+ }
+});
+
/* ------------------------------------------------------------------
USER REGISTRATION (MySQL)
------------------------------------------------------------------ */
app.post('/api/register', async (req, res) => {
const {
username, password, firstname, lastname, email,
- zipcode, state, area, career_situation, phone_e164, sms_opt_in
+ date_of_birth, // NEW: DOB for COPPA compliance
+ zipcode, state, area, career_situation, phone_e164, sms_opt_in,
+ inviteToken // NEW: Invitation token from organization admin
} = req.body;
- if (!username || !password || !firstname || !lastname || !email || !zipcode || !state || !area) {
+ if (!username || !password || !firstname || !lastname || !email || !date_of_birth || !zipcode || !state || !area) {
return res.status(400).json({ error: 'Missing required fields.' });
}
+ // Validate DOB format and age (COPPA compliance - must be 13+)
+ const dobRegex = /^\d{4}-\d{2}-\d{2}$/;
+ if (!dobRegex.test(date_of_birth)) {
+ return res.status(400).json({ error: 'Invalid date of birth format. Expected YYYY-MM-DD.' });
+ }
+
+ const birthDate = new Date(date_of_birth);
+ const today = new Date();
+ let age = today.getFullYear() - birthDate.getFullYear();
+ const monthDiff = today.getMonth() - birthDate.getMonth();
+ if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
+ age--;
+ }
+
+ if (age < 13) {
+ return res.status(403).json({ error: 'You must be at least 13 years old to use AptivaAI.' });
+ }
+
if (sms_opt_in && !/^\+\d{8,15}$/.test(phone_e164 || '')) {
return res.status(400).json({ error: 'Phone must be +E.164 format.' });
}
try {
- const hashedPassword = await bcrypt.hash(password, 10);
+ let userId;
+ let isInvitedStudent = false;
+ let organizationId = null;
- const emailNorm = String(email).trim().toLowerCase();
- const encEmail = encrypt(emailNorm); // if encrypt() is async in your lib, use: await encrypt(...)
- const emailLookupVal = emailLookup(emailNorm);
+ // Check if this is completing an invitation
+ if (inviteToken) {
+ try {
+ const decoded = jwt.verify(inviteToken, JWT_SECRET);
- const [resultProfile] = await pool.query(`
- INSERT INTO user_profile
- (username, firstname, lastname, email, email_lookup, zipcode, state, area,
- career_situation, phone_e164, sms_opt_in)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
- `, [
- username, firstname, lastname, encEmail, emailLookupVal,
- zipcode, state, area, career_situation || null,
- phone_e164 || null, sms_opt_in ? 1 : 0
- ]);
+ if (decoded.prp === 'student_invite') {
+ userId = decoded.userId;
+ organizationId = decoded.organizationId;
+ isInvitedStudent = true;
+ // Verify user still exists and username is NULL
+ const [existingUser] = await pool.query(
+ 'SELECT id, username FROM user_profile WHERE id = ? LIMIT 1',
+ [userId]
+ );
- const newProfileId = resultProfile.insertId;
+ if (!existingUser.length) {
+ return res.status(404).json({ error: 'Invitation not found or expired.' });
+ }
- const authQuery = `INSERT INTO user_auth (user_id, username, hashed_password) VALUES (?, ?, ?)`;
- await pool.query(authQuery, [newProfileId, username, hashedPassword]);
+ if (existingUser[0].username) {
+ return res.status(400).json({ error: 'This invitation has already been used. Please sign in instead.' });
+ }
- const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' });
+ // Update existing user_profile with username and other details
+ await pool.query(
+ 'UPDATE user_profile SET username = ?, zipcode = ?, state = ?, area = ?, career_situation = ?, phone_e164 = ?, sms_opt_in = ? WHERE id = ?',
+ [username, zipcode, state, area, career_situation || null, phone_e164 || null, sms_opt_in ? 1 : 0, userId]
+ );
+
+ // Create user_auth for login (with DOB for COPPA compliance)
+ const hashedPassword = await bcrypt.hash(password, 10);
+ const usernameLookupVal = usernameLookup(username);
+ await pool.query(
+ 'INSERT INTO user_auth (user_id, username, username_lookup, hashed_password, date_of_birth, age_verified_at) VALUES (?, ?, ?, ?, ?, NOW())',
+ [userId, username, usernameLookupVal, hashedPassword, date_of_birth]
+ );
+
+ // Update organization_students status to active
+ await pool.query(
+ 'UPDATE organization_students SET enrollment_status = ?, invitation_accepted_at = NOW() WHERE organization_id = ? AND user_id = ?',
+ ['active', organizationId, userId]
+ );
+
+ console.log(`[register] Invitation completed for user ${userId} in org ${organizationId}`);
+ }
+ } catch (tokenErr) {
+ console.error('[register] Invalid invite token:', tokenErr.message);
+ // Continue as normal signup if token is invalid
+ isInvitedStudent = false;
+ }
+ }
+
+ // If not invited student, create new user (existing logic)
+ if (!isInvitedStudent) {
+ const hashedPassword = await bcrypt.hash(password, 10);
+
+ const emailNorm = String(email).trim().toLowerCase();
+ const encEmail = encrypt(emailNorm);
+ const emailLookupVal = emailLookup(emailNorm);
+
+ // Check for duplicate email (except for test account bypass)
+ if (emailNorm !== 'jcoakley@aptivaai.com') {
+ const [existingUser] = await pool.query(
+ 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1',
+ [emailLookupVal]
+ );
+
+ if (existingUser.length > 0) {
+ return res.status(409).json({ error: 'An account with this email already exists.' });
+ }
+ } else {
+ console.log('[register] Test account bypass for jcoakley@aptivaai.com - allowing duplicate');
+ }
+
+ const [resultProfile] = await pool.query(`
+ INSERT INTO user_profile
+ (username, firstname, lastname, email, email_lookup, zipcode, state, area,
+ career_situation, phone_e164, sms_opt_in)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ `, [
+ username, firstname, lastname, encEmail, emailLookupVal,
+ zipcode, state, area, career_situation || null,
+ phone_e164 || null, sms_opt_in ? 1 : 0
+ ]);
+
+ userId = resultProfile.insertId;
+
+ const usernameLookupVal = usernameLookup(username);
+ const authQuery = `INSERT INTO user_auth (user_id, username, username_lookup, hashed_password, date_of_birth, age_verified_at) VALUES (?, ?, ?, ?, ?, NOW())`;
+ await pool.query(authQuery, [userId, username, usernameLookupVal, hashedPassword, date_of_birth]);
+ }
+
+ const token = jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
return res.status(201).json({
message: 'User registered successfully',
- profileId: newProfileId,
+ profileId: userId,
token,
user: {
- username, firstname, lastname, email: emailNorm, zipcode, state, area,
+ username, firstname, lastname, email: email, zipcode, state, area,
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
}
});
@@ -954,16 +1295,19 @@ app.post('/api/signin', signinLimiter, async (req, res) => {
return res.status(400).json({ error: 'Both username and password are required' });
}
+ // Use username_lookup hash for querying (username is encrypted)
+ const usernameLookupVal = usernameLookup(username);
+
// Only fetch what you need to verify creds
const query = `
SELECT ua.user_id AS userProfileId, ua.hashed_password
FROM user_auth ua
- WHERE ua.username = ?
+ WHERE ua.username_lookup = ?
LIMIT 1
`;
try {
- const [results] = await pool.query(query, [username]);
+ const [results] = await pool.query(query, [usernameLookupVal]);
if (!results || results.length === 0) {
return res.status(401).json({ error: 'Invalid username or password' });
}
@@ -974,6 +1318,9 @@ app.post('/api/signin', signinLimiter, async (req, res) => {
return res.status(401).json({ error: 'Invalid username or password' });
}
+ // Update last_login timestamp
+ await pool.execute('UPDATE user_profile SET last_login = NOW() WHERE id = ?', [userProfileId]);
+
// Cookie-based session only; do NOT return id/token/user in body
const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
@@ -1052,10 +1399,17 @@ app.post('/api/user-profile', requireAuth, async (req, res) => {
const finalUserName = (userName !== undefined)
? userName
: existing?.username ?? null;
- const finalRiasec = riasec_scores
+ const finalRiasec = (riasec_scores !== undefined)
? JSON.stringify(riasec_scores)
: existing?.riasec_scores ?? null;
+ console.log('[user-profile] RIASEC debug:', {
+ riasec_scores_from_body: riasec_scores,
+ riasec_scores_undefined: riasec_scores === undefined,
+ finalRiasec: finalRiasec,
+ finalRiasec_length: finalRiasec?.length
+ });
+
// Normalize email and compute lookup iff email is provided (or keep existing)
const safeDecrypt = (v) => { try { return decrypt(v); } catch { return v; } };
@@ -1241,6 +1595,81 @@ app.get('/api/user-profile', requireAuth, async (req, res) => {
});
+/* ------------------------------------------------------------------
+ CHECK ONBOARDING STATUS (MySQL)
+ ------------------------------------------------------------------ */
+app.get('/api/onboarding-status', requireAuth, async (req, res) => {
+ const userId = req.userId;
+ try {
+ // Check if user is in an organization and if onboarding should be triggered
+ const [rows] = await pool.query(`
+ SELECT
+ os.onboarding_triggered_at,
+ os.onboarding_completed,
+ os.grade_level
+ FROM organization_students os
+ WHERE os.user_id = ?
+ AND os.enrollment_status NOT IN ('withdrawn', 'transferred', 'inactive')
+ ORDER BY os.enrollment_date DESC
+ LIMIT 1
+ `, [userId]);
+
+ if (!rows || rows.length === 0) {
+ // User not in any organization - no onboarding needed
+ return res.json({ shouldTrigger: false });
+ }
+
+ const { onboarding_triggered_at, onboarding_completed, grade_level } = rows[0];
+
+ // Check if trigger date has passed and onboarding not completed
+ // Note: grade_level check is not needed here as trigger date is only set when appropriate
+ // (grades 11-12 for K-12 schools, or immediately for colleges/universities)
+ const now = new Date();
+ const triggerDate = onboarding_triggered_at ? new Date(onboarding_triggered_at) : null;
+
+ const shouldTrigger =
+ triggerDate &&
+ triggerDate <= now &&
+ !onboarding_completed;
+
+ return res.json({
+ shouldTrigger: !!shouldTrigger,
+ triggerDate,
+ onboardingCompleted: !!onboarding_completed,
+ gradeLevel: grade_level
+ });
+
+ } catch (err) {
+ console.error('Error checking onboarding status:', err?.message || err);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
+
+/* ------------------------------------------------------------------
+ MARK ONBOARDING COMPLETED (MySQL)
+ ------------------------------------------------------------------ */
+app.post('/api/onboarding-completed', requireAuth, async (req, res) => {
+ const userId = req.userId;
+ try {
+ // Mark onboarding as completed for user's active enrollment
+ await pool.execute(`
+ UPDATE organization_students
+ SET onboarding_completed = TRUE,
+ onboarding_triggered = TRUE,
+ updated_at = NOW()
+ WHERE user_id = ?
+ AND enrollment_status NOT IN ('withdrawn', 'transferred', 'inactive')
+ `, [userId]);
+
+ return res.json({ message: 'Onboarding marked as completed' });
+
+ } catch (err) {
+ console.error('Error marking onboarding complete:', err?.message || err);
+ return res.status(500).json({ error: 'Internal server error' });
+ }
+});
+
/* ------------------------------------------------------------------
SALARY_INFO REMAINS IN SQLITE
@@ -1308,6 +1737,179 @@ app.post('/api/activate-premium', requireAuth, async (req, res) => {
}
});
+/* ------------------------------------------------------------------
+ STUDENT PRIVACY SETTINGS ENDPOINTS
+ ------------------------------------------------------------------ */
+
+// Get privacy settings for all organizations student belongs to
+app.get('/api/privacy-settings', requireAuth, async (req, res) => {
+ const userId = req.userId;
+
+ try {
+ // Get all organizations this user is enrolled in
+ const [enrollments] = await pool.execute(`
+ SELECT os.organization_id, o.organization_name
+ FROM organization_students os
+ JOIN organizations o ON os.organization_id = o.id
+ WHERE os.user_id = ? AND os.enrollment_status = 'active'
+ `, [userId]);
+
+ if (enrollments.length === 0) {
+ return res.json({ organizations: [] });
+ }
+
+ // Get privacy settings for each organization
+ const settingsPromises = enrollments.map(async (enrollment) => {
+ const [settings] = await pool.execute(`
+ SELECT *
+ FROM student_privacy_settings
+ WHERE user_id = ? AND organization_id = ?
+ LIMIT 1
+ `, [userId, enrollment.organization_id]);
+
+ // Default to all false (private) if no settings exist
+ const hasConfigured = settings.length > 0;
+ const privacySettings = settings[0] || {
+ share_career_exploration: false,
+ share_interest_inventory: false,
+ share_career_profiles: false,
+ share_college_profiles: false,
+ share_financial_profile: false,
+ share_roadmap: false
+ };
+
+ return {
+ organization_id: enrollment.organization_id,
+ organization_name: enrollment.organization_name,
+ settings: privacySettings,
+ has_configured: hasConfigured
+ };
+ });
+
+ const allSettings = await Promise.all(settingsPromises);
+
+ res.json({ organizations: allSettings });
+ } catch (err) {
+ console.error('[privacy-settings GET] Error:', err.message);
+ res.status(500).json({ error: 'Failed to load privacy settings' });
+ }
+});
+
+// Update privacy settings for a specific organization
+app.post('/api/privacy-settings', requireAuth, async (req, res) => {
+ const userId = req.userId;
+ const {
+ organization_id,
+ share_career_exploration,
+ share_interest_inventory,
+ share_career_profiles,
+ share_college_profiles,
+ share_financial_profile,
+ share_roadmap
+ } = req.body;
+
+ if (!organization_id) {
+ return res.status(400).json({ error: 'organization_id is required' });
+ }
+
+ try {
+ // Verify user is enrolled in this organization
+ const [enrollment] = await pool.execute(`
+ SELECT id FROM organization_students
+ WHERE user_id = ? AND organization_id = ? AND enrollment_status = 'active'
+ LIMIT 1
+ `, [userId, organization_id]);
+
+ if (!enrollment || enrollment.length === 0) {
+ return res.status(403).json({ error: 'Not enrolled in this organization' });
+ }
+
+ // Check if settings already exist
+ const [existing] = await pool.execute(`
+ SELECT id FROM student_privacy_settings
+ WHERE user_id = ? AND organization_id = ?
+ LIMIT 1
+ `, [userId, organization_id]);
+
+ if (existing && existing.length > 0) {
+ // Update existing settings
+ await pool.execute(`
+ UPDATE student_privacy_settings
+ SET
+ share_career_exploration = ?,
+ share_interest_inventory = ?,
+ share_career_profiles = ?,
+ share_college_profiles = ?,
+ share_financial_profile = ?,
+ share_roadmap = ?,
+ updated_at = UTC_TIMESTAMP()
+ WHERE user_id = ? AND organization_id = ?
+ `, [
+ share_career_exploration ?? false,
+ share_interest_inventory ?? false,
+ share_career_profiles ?? false,
+ share_college_profiles ?? false,
+ share_financial_profile ?? false,
+ share_roadmap ?? false,
+ userId,
+ organization_id
+ ]);
+ } else {
+ // Insert new settings (default to false for privacy)
+ await pool.execute(`
+ INSERT INTO student_privacy_settings
+ (user_id, organization_id, share_career_exploration, share_interest_inventory,
+ share_career_profiles, share_college_profiles, share_financial_profile, share_roadmap,
+ updated_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())
+ `, [
+ userId,
+ organization_id,
+ share_career_exploration ?? false,
+ share_interest_inventory ?? false,
+ share_career_profiles ?? false,
+ share_college_profiles ?? false,
+ share_financial_profile ?? false,
+ share_roadmap ?? false
+ ]);
+ }
+
+ res.json({ message: 'Privacy settings updated successfully' });
+ } catch (err) {
+ console.error('[privacy-settings POST] Error:', err.message);
+ res.status(500).json({ error: 'Failed to update privacy settings' });
+ }
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// CAREER VIEW TRACKING
+// ═══════════════════════════════════════════════════════════════════════════
+
+app.post('/api/track-career-view', requireAuth, async (req, res) => {
+ try {
+ const userId = req.userId;
+ if (!userId) {
+ return res.status(401).json({ error: 'Unauthorized' });
+ }
+
+ const { career_soc_code, career_name } = req.body;
+ if (!career_soc_code || !career_name) {
+ return res.status(400).json({ error: 'career_soc_code and career_name are required' });
+ }
+
+ // Insert career view (fire and forget - don't need to wait for response)
+ pool.execute(
+ 'INSERT INTO career_views (user_id, career_soc_code, career_name) VALUES (?, ?, ?)',
+ [userId, career_soc_code, career_name]
+ ).catch(err => console.error('[track-career-view] Failed to insert:', err.message));
+
+ res.json({ success: true });
+ } catch (err) {
+ console.error('[track-career-view] Error:', err.message);
+ res.status(500).json({ error: 'Failed to track career view' });
+ }
+});
+
app.use((err, req, res, _next) => {
if (res.headersSent) return;
const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res);
diff --git a/backend/server2.js b/backend/server2.js
index 432c9e0..ca20a08 100755
--- a/backend/server2.js
+++ b/backend/server2.js
@@ -1067,25 +1067,37 @@ app.post('/api/onet/submit_answers', async (req, res) => {
const filtered = filterHigherEducationCareers(careerSuggestions);
const riasecCode = convertToRiasecCode(riaSecScores);
+ // Convert RIASEC scores to object format: {"R":23,"I":25,"A":23,"S":16,"E":15,"C":22}
+ const riasecScoresObject = {};
+ riaSecScores.forEach(item => {
+ riasecScoresObject[item.area[0].toUpperCase()] = item.score;
+ });
+
+ console.log('[submit_answers] RIASEC scores object:', riasecScoresObject);
+
// Pass the caller's Bearer straight through to server1 (if present)
const bearer = req.headers.authorization; // e.g. "Bearer eyJ..."
if (bearer) {
+ console.log('[submit_answers] Sending to /api/user-profile with RIASEC:', riasecScoresObject);
try {
- await axios.post(
+ const response = await axios.post(
`${API_BASE}/api/user-profile`,
{
interest_inventory_answers: answers,
- riasec: riasecCode,
+ riasec: riasecScoresObject,
},
{ headers: { Authorization: bearer } }
);
+ console.log('[submit_answers] Successfully saved RIASEC scores');
} catch (err) {
console.error(
- 'Error storing RIASEC in user_profile =>',
+ '[submit_answers] Error storing RIASEC in user_profile =>',
err.response?.data || err.message
);
// non-fatal for the O*NET response
}
+ } else {
+ console.log('[submit_answers] No bearer token, skipping RIASEC save');
}
res.status(200).json({
@@ -1141,7 +1153,7 @@ function convertToRiasecCode(riaSecScores) {
}
// ONet career details
-app.get('/api/onet/career-details/:socCode', async (req, res) => {
+app.get('/api/onet/career-details/:socCode', authenticateUser, async (req, res) => {
const { socCode } = req.params;
if (!socCode) {
return res.status(400).json({ error: 'SOC code is required' });
@@ -1154,6 +1166,7 @@ app.get('/api/onet/career-details/:socCode', async (req, res) => {
},
headers: { Accept: 'application/json' },
});
+
res.status(200).json(response.data);
} catch (err) {
console.error('Error fetching career details:', err);
diff --git a/backend/server3.js b/backend/server3.js
index 9871849..4d0ea83 100644
--- a/backend/server3.js
+++ b/backend/server3.js
@@ -1149,6 +1149,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
const {
scenario_title,
career_name,
+ career_soc_code,
status,
start_date,
college_enrollment_status,
@@ -1174,20 +1175,21 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
try {
const finalId = req.body.id || uuidv4();
- // 1) Insert includes career_goals
+ // 1) Insert includes career_goals and career_soc_code
const sql = `
INSERT INTO career_profiles (
id,
user_id,
scenario_title,
career_name,
+ career_soc_code,
status,
start_date,
college_enrollment_status,
currently_working,
career_goals,
retirement_start_date,
- desired_retirement_income_monthly,
+ desired_retirement_income_monthly,
planned_monthly_expenses,
planned_monthly_debt_payments,
planned_monthly_retirement_contribution,
@@ -1196,8 +1198,9 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
planned_surplus_retirement_pct,
planned_additional_income
)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
+ career_soc_code = VALUES(career_soc_code),
status = VALUES(status),
start_date = VALUES(start_date),
college_enrollment_status = VALUES(college_enrollment_status),
@@ -1220,6 +1223,7 @@ app.post('/api/premium/career-profile', authenticatePremiumUser, async (req, res
req.id,
scenario_title || null,
career_name,
+ career_soc_code || null,
status || 'planned',
start_date || null,
college_enrollment_status || null,
@@ -1479,11 +1483,7 @@ const lastAssistantIsOneShotQ =
const _scenarioRow = scenarioRow || {};
const _financialProfile = financialProfile || {};
const _collegeProfile = collegeProfile || {};
- // 1) USER PROFILE
- const firstName = userProfile.firstname || "N/A";
- const lastName = userProfile.lastname || "N/A";
- const fullName = `${firstName} ${lastName}`;
- const username = _userProfile.username || "N/A";
+ // 1) USER PROFILE (PII removed - no names/username sent to AI)
const location = _userProfile.area || _userProfile.state || "Unknown Region";
const careerSituation = _userProfile.career_situation || "Not provided";
@@ -1604,11 +1604,9 @@ Occupation: ${economicProjections.national.occupationName}
`.trim();
}
- // 8) BUILD THE FINAL TEXT
+ // 8) BUILD THE FINAL TEXT (PII removed - no student names/usernames)
return `
[USER PROFILE]
-- Full Name: ${fullName}
-- Username: ${username}
- Location: ${location}
- Career Situation: ${careerSituation}
- RIASEC:
diff --git a/backend/server4.js b/backend/server4.js
new file mode 100644
index 0000000..0b96243
--- /dev/null
+++ b/backend/server4.js
@@ -0,0 +1,3129 @@
+/**************************************************
+ * server4.js - Organization Admin Portal API
+ * Port: 5003
+ * Purpose: B2B admin endpoints for schools/colleges
+ **************************************************/
+
+import express from 'express';
+import helmet from 'helmet';
+import dotenv from 'dotenv';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import pool from './config/mysqlPool.js';
+import cookieParser from 'cookie-parser';
+import jwt from 'jsonwebtoken';
+import bcrypt from 'bcrypt';
+import rateLimit from 'express-rate-limit';
+import crypto from 'crypto';
+import { readFile } from 'fs/promises';
+import sgMail from '@sendgrid/mail';
+import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
+import axios from 'axios';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+const rootPath = path.resolve(__dirname, '..');
+const env = (process.env.ENV_NAME === 'prod');
+const envPath = path.resolve(rootPath, `.env.${env}`);
+dotenv.config({ path: envPath, override: false });
+
+const {
+ JWT_SECRET,
+ CORS_ALLOWED_ORIGINS,
+ SERVER4_PORT = 5003,
+ ADMIN_PORTAL_URL
+} = process.env;
+
+if (!JWT_SECRET) {
+ console.error('FATAL: JWT_SECRET missing – aborting startup');
+ process.exit(1);
+}
+if (!CORS_ALLOWED_ORIGINS) {
+ console.error('FATAL: CORS_ALLOWED_ORIGINS missing – aborting startup');
+ process.exit(1);
+}
+
+// SendGrid configuration (match server2.js exactly)
+const SENDGRID_KEY = (process.env.SUPPORT_SENDGRID_API_KEY || '')
+ .trim()
+ .replace(/^['"]+|['"]+$/g, ''); // strip leading/trailing quotes if GCP injects them
+
+if (SENDGRID_KEY) {
+ sgMail.setApiKey(SENDGRID_KEY);
+ console.log('[MAIL] SendGrid enabled');
+} else {
+ console.warn('[MAIL] SUPPORT_SENDGRID_API_KEY missing/empty; invitation emails will be logged only');
+}
+
+const CANARY_SQL = `
+ CREATE TABLE IF NOT EXISTS encryption_canary (
+ id TINYINT NOT NULL PRIMARY KEY,
+ value TEXT NOT NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`;
+
+// ═══════════════════════════════════════════════════════════════════════════
+// DEK BOOTSTRAP + CANARY VERIFICATION (BEFORE SERVING TRAFFIC)
+// ═══════════════════════════════════════════════════════════════════════════
+
+try {
+ await initEncryption();
+
+ const db = pool.raw || pool;
+
+ // Quick connectivity check
+ await db.query('SELECT 1');
+
+ // Ensure canary table exists
+ await db.query(CANARY_SQL);
+
+ // Insert sentinel on first run (ignore if exists)
+ await db.query(
+ 'INSERT IGNORE INTO encryption_canary (id, value) VALUES (1, ?)',
+ [encrypt(SENTINEL)]
+ );
+
+ // Read back & verify
+ const [rows] = await db.query(
+ 'SELECT value FROM encryption_canary WHERE id = 1 LIMIT 1'
+ );
+ const plaintext = decrypt(rows[0]?.value || '');
+ if (plaintext !== SENTINEL) {
+ throw new Error('DEK mismatch with database sentinel');
+ }
+
+ console.log('[ENCRYPT] DEK verified against canary – proceeding');
+} catch (err) {
+ console.error('FATAL:', err?.message || err);
+ process.exit(1);
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// LOAD ECONOMIC PROJECTIONS DATA
+// ═══════════════════════════════════════════════════════════════════════════
+
+const DATA_DIR = path.join(__dirname, 'data');
+const PROJ_FILE = path.join(DATA_DIR, 'economicproj.json');
+
+let allProjections = [];
+try {
+ const raw = await readFile(PROJ_FILE, 'utf8');
+ allProjections = JSON.parse(raw);
+ console.log(`[DATA] Loaded ${allProjections.length} economic projection rows`);
+} catch (err) {
+ console.warn('[DATA] Failed to load economicproj.json:', err.message);
+}
+
+// Helper to get full state name from abbreviation
+function fullStateFrom(s = '') {
+ const M = {
+ AL:'Alabama', AK:'Alaska', AZ:'Arizona', AR:'Arkansas', CA:'California', CO:'Colorado',
+ CT:'Connecticut', DE:'Delaware', DC:'District of Columbia', FL:'Florida', GA:'Georgia',
+ HI:'Hawaii', ID:'Idaho', IL:'Illinois', IN:'Indiana', IA:'Iowa', KS:'Kansas',
+ KY:'Kentucky', LA:'Louisiana', ME:'Maine', MD:'Maryland', MA:'Massachusetts',
+ MI:'Michigan', MN:'Minnesota', MS:'Mississippi', MO:'Missouri', MT:'Montana',
+ NE:'Nebraska', NV:'Nevada', NH:'New Hampshire', NJ:'New Jersey', NM:'New Mexico',
+ NY:'New York', NC:'North Carolina', ND:'North Dakota', OH:'Ohio', OK:'Oklahoma',
+ OR:'Oregon', PA:'Pennsylvania', RI:'Rhode Island', SC:'South Carolina',
+ SD:'South Dakota', TN:'Tennessee', TX:'Texas', UT:'Utah', VT:'Vermont',
+ VA:'Virginia', WA:'Washington', WV:'West Virginia', WI:'Wisconsin', WY:'Wyoming'
+ };
+ if (!s) return '';
+ const up = String(s).trim().toUpperCase();
+ return M[up] || s; // already full name → return as-is
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// EXPRESS APP & MIDDLEWARE
+// ═══════════════════════════════════════════════════════════════════════════
+
+const app = express();
+const PORT = process.env.SERVER4_PORT || 5003;
+const ADMIN_COOKIE_NAME = 'aptiva_admin_session';
+
+app.disable('x-powered-by');
+app.use(express.json({ limit: '10mb' }));
+app.set('trust proxy', 1); // behind proxy/HTTPS in all envs
+app.use(cookieParser());
+app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false }));
+
+app.use((req, res, next) => {
+ if (req.path.startsWith('/api/')) res.type('application/json');
+ next();
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// REQUEST ID + MINIMAL AUDIT LOG FOR /api/*
+// ═══════════════════════════════════════════════════════════════════════════
+
+function getRequestId(req, res) {
+ const hdr = req.headers['x-request-id'];
+ if (typeof hdr === 'string' && hdr) return hdr; // from Nginx
+ const rid = crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`;
+ res.setHeader('X-Request-ID', rid);
+ return rid;
+}
+
+app.use((req, res, next) => {
+ if (!req.path.startsWith('/api/')) return next();
+
+ const rid = getRequestId(req, res);
+ const t0 = process.hrtime.bigint();
+
+ res.on('finish', () => {
+ const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n);
+ const out = {
+ ts: new Date().toISOString(),
+ rid,
+ ip: req.ip || req.headers['x-forwarded-for'] || '',
+ method: req.method,
+ path: req.path,
+ status: res.statusCode,
+ dur_ms: durMs,
+ bytes_sent: Number(res.getHeader('Content-Length') || 0),
+ userId: req.userId || req.admin?.userId || null
+ };
+ try { console.log(JSON.stringify(out)); } catch {}
+ });
+
+ next();
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// DETAILED AUDIT LOGGING (REDACT SENSITIVE DATA)
+// ═══════════════════════════════════════════════════════════════════════════
+
+function pickIp(req) {
+ return req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || '';
+}
+
+function redactHeaders(h) {
+ const out = { ...h };
+ delete out.authorization;
+ delete out.cookie;
+ delete out['x-forwarded-for'];
+ return out;
+}
+
+function sampleBody(b) {
+ if (!b || typeof b !== 'object') return undefined;
+ const keys = Object.keys(b);
+ const preview = {};
+ for (const k of keys.slice(0, 12)) {
+ const v = b[k];
+ preview[k] = typeof v === 'string' ? (v.length > 80 ? v.slice(0, 80) + '…' : v) : (Array.isArray(v) ? `[array:${v.length}]` : typeof v);
+ }
+ return preview;
+}
+
+app.use((req, res, next) => {
+ if (!req.path.startsWith('/api/')) return next();
+
+ const rid = req.headers['x-request-id'] || crypto.randomUUID?.() || String(Date.now());
+ res.setHeader('X-Request-ID', rid);
+ const t0 = process.hrtime.bigint();
+
+ const reqLog = {
+ ts: new Date().toISOString(),
+ rid,
+ ip: pickIp(req),
+ method: req.method,
+ path: req.path,
+ userId: req.userId || req.admin?.userId || null,
+ ua: req.headers['user-agent'] || '',
+ hdr: redactHeaders(req.headers),
+ body: sampleBody(req.body)
+ };
+
+ res.on('finish', () => {
+ const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n);
+ const out = {
+ ...reqLog,
+ status: res.statusCode,
+ dur_ms: durMs,
+ bytes_sent: Number(res.getHeader('Content-Length') || 0)
+ };
+ try { console.log(JSON.stringify(out)); } catch {}
+ });
+
+ next();
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// RUNTIME: NEVER CACHE API RESPONSES
+// ═══════════════════════════════════════════════════════════════════════════
+
+app.use((req, res, next) => {
+ if (req.path.startsWith('/api/')) {
+ res.set('Cache-Control', 'no-store');
+ res.set('Pragma', 'no-cache');
+ res.set('Expires', '0');
+ }
+ next();
+});
+
+process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e));
+process.on('uncaughtException', (e) => console.error('[uncaughtException]', e));
+
+// ═══════════════════════════════════════════════════════════════════════════
+// RUNTIME: ENFORCE JSON ON API WRITES (WITH NARROW EXCEPTIONS)
+// ═══════════════════════════════════════════════════════════════════════════
+
+const MUST_JSON = new Set(['POST','PUT','PATCH']);
+const EXEMPT_PATHS = [
+ // Add any multipart/form-data endpoints here if needed
+];
+
+app.use((req, res, next) => {
+ if (!req.path.startsWith('/api/')) return next();
+ if (!MUST_JSON.has(req.method)) return next();
+ if (EXEMPT_PATHS.some(rx => rx.test(req.path))) return next();
+
+ const ct = req.headers['content-type'] || '';
+ if (!ct.toLowerCase().includes('application/json')) {
+ return res.status(415).json({ error: 'unsupported_media_type' });
+ }
+ next();
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// RUNTIME PROTECTION: HPP GUARD (DEDUPE + CAP ARRAYS)
+// ═══════════════════════════════════════════════════════════════════════════
+
+app.use((req, _res, next) => {
+ const MAX_ARRAY = 20;
+
+ const sanitize = (obj) => {
+ if (!obj || typeof obj !== 'object') return;
+ for (const k of Object.keys(obj)) {
+ const v = obj[k];
+ if (Array.isArray(v)) {
+ obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null);
+ if (obj[k].length === 1) obj[k] = obj[k][0];
+ }
+ }
+ };
+
+ sanitize(req.query);
+ sanitize(req.body);
+ next();
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// RUNTIME: REJECT REQUEST BODIES ON GET/HEAD
+// ═══════════════════════════════════════════════════════════════════════════
+
+app.use((req, res, next) => {
+ if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) {
+ return res.status(400).json({ error: 'no_body_allowed' });
+ }
+ next();
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// RUNTIME: LAST-RESORT ERROR SANITIZER
+// ═══════════════════════════════════════════════════════════════════════════
+
+app.use((err, req, res, _next) => {
+ if (res.headersSent) return;
+
+ if (err?.code === 'LIMIT_FILE_SIZE') {
+ return res.status(413).json({ error: 'file_too_large', limit_mb: 10 });
+ }
+ if (err?.message && String(err.message).startsWith('blocked_outbound_host:')) {
+ return res.status(400).json({ error: 'blocked_outbound_host' });
+ }
+ if (err?.message === 'unsupported_type') {
+ return res.status(415).json({ error: 'unsupported_type' });
+ }
+
+ console.error('[unhandled]', err?.message || err);
+ return res.status(500).json({ error: 'Server error' });
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// CORS (STRICT ALLOWLIST FROM ENV)
+// ═══════════════════════════════════════════════════════════════════════════
+
+const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
+ .split(',')
+ .map(o => o.trim())
+ .filter(Boolean);
+
+app.use((req, res, next) => {
+ const origin = req.headers.origin || '';
+ if (!origin) return next();
+ if (!allowedOrigins.includes(origin)) {
+ return res.status(403).end();
+ }
+ res.setHeader('Access-Control-Allow-Origin', origin);
+ res.setHeader('Access-Control-Allow-Credentials', 'true');
+ res.setHeader(
+ 'Access-Control-Allow-Headers',
+ 'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods'
+ );
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
+ if (req.method === 'OPTIONS') return res.status(204).end();
+ return next();
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// SESSION COOKIE OPTIONS
+// ═══════════════════════════════════════════════════════════════════════════
+
+function sessionCookieOptions() {
+ const IS_HTTPS = true;
+ const CROSS_SITE = process.env.CROSS_SITE_COOKIES === '1';
+ const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
+
+ return {
+ httpOnly: true,
+ secure: IS_HTTPS,
+ sameSite: CROSS_SITE ? 'none' : 'lax',
+ path: '/',
+ maxAge: 8 * 60 * 60 * 1000, // 8 hours for admin sessions
+ ...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}),
+ };
+}
+
+function fprPathFromEnv() {
+ const p = (process.env.DEK_PATH || '').trim();
+ return p ? path.join(path.dirname(p), 'dek.fpr') : null;
+}
+
+// ═══════════════════════════════════════════════════════════════════════════
+// HEALTH CHECK ENDPOINTS
+// ═══════════════════════════════════════════════════════════════════════════
+
+// 1) Liveness: process is up and event loop responsive
+app.get('/livez', (_req, res) => res.type('text').send('OK'));
+
+// 2) Readiness: crypto + canary are good
+app.get('/readyz', async (_req, res) => {
+ try {
+ await initEncryption();
+ await verifyCanary(pool);
+ return res.type('text').send('OK');
+ } catch (e) {
+ console.error('[READYZ]', e.message);
+ return res.status(500).type('text').send('FAIL');
+ }
+});
+
+// 3) Health: detailed JSON
+app.get('/healthz', async (_req, res) => {
+ const out = {
+ service: 'server4-admin-api',
+ version: process.env.IMG_TAG || null,
+ uptime_s: Math.floor(process.uptime()),
+ now: new Date().toISOString(),
+ checks: {
+ live: { ok: true },
+ crypto: { ok: false, fp: null },
+ db: { ok: false, ping_ms: null },
+ canary: { ok: false }
+ }
+ };
+
+ // crypto / DEK
+ try {
+ await initEncryption();
+ out.checks.crypto.ok = true;
+ const p = fprPathFromEnv();
+ if (p) {
+ try { out.checks.crypto.fp = (await readFile(p, 'utf8')).trim(); }
+ catch { /* fp optional */ }
+ }
+ } catch (e) {
+ out.checks.crypto.error = e.message;
+ }
+
+ // DB ping
+ const t0 = Date.now();
+ try {
+ await pool.query('SELECT 1');
+ out.checks.db.ok = true;
+ out.checks.db.ping_ms = Date.now() - t0;
+ } catch (e) {
+ out.checks.db.error = e.message;
+ }
+
+ // canary
+ try {
+ await verifyCanary(pool);
+ out.checks.canary.ok = true;
+ } catch (e) {
+ out.checks.canary.error = e.message;
+ }
+
+ const ready = out.checks.crypto.ok && out.checks.db.ok && out.checks.canary.ok;
+ return res.status(ready ? 200 : 503).json(out);
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// RATE LIMITERS
+// ═══════════════════════════════════════════════════════════════════════════
+
+const adminLoginLimiter = rateLimit({
+ windowMs: 15 * 60 * 1000,
+ max: 10,
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => req.ip
+});
+
+const bulkUploadLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 5,
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => req.ip
+});
+
+const rosterUploadLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 3,
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => req.ip
+});
+
+const exportLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 10,
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => req.ip
+});
+
+const resendLimiter = rateLimit({
+ windowMs: 60 * 1000,
+ max: 20,
+ standardHeaders: true,
+ legacyHeaders: false,
+ keyGenerator: (req) => req.ip
+});
+
+// ═══════════════════════════════════════════════════════════════════════════
+// EMAIL INVITATION HELPER
+// ═══════════════════════════════════════════════════════════════════════════
+
+// Send invitation to NEW users (no existing AptivaAI account)
+async function sendNewUserInvitation(email, firstname, organizationName, organizationId, userId) {
+ const inviteToken = jwt.sign(
+ {
+ email,
+ userId,
+ organizationId,
+ prp: 'student_invite',
+ isNewUser: true
+ },
+ JWT_SECRET,
+ { expiresIn: '7d' }
+ );
+
+ const MAIN_APP_URL = process.env.APTIVA_API_BASE || 'https://aptivaai.com';
+ const inviteLink = `${MAIN_APP_URL}/signup?invite=${inviteToken}`;
+ const linkAccountLink = `${MAIN_APP_URL}/link-secondary-email?token=${inviteToken}`;
+
+ const text = `Hi ${firstname},
+
+You've been invited to join ${organizationName} on AptivaAI!
+
+AptivaAI is a career exploration platform that helps you discover potential career paths, explore educational programs, and plan your professional future.
+
+Click the link below to create your account (valid for 7 days):
+${inviteLink}
+
+Already have an AptivaAI account with a different email? Link it here:
+${linkAccountLink}
+
+Questions? Reply to this email or contact your administrator.
+
+— The AptivaAI Team`;
+
+ const html = `
+
Welcome to AptivaAI!
+
Hi ${firstname},
+
You've been invited to join ${organizationName} on AptivaAI!
+
AptivaAI is a career exploration platform that helps you discover potential career paths, explore educational programs, and plan your professional future.
Questions? Reply to this email or contact your administrator.
+
+
— The AptivaAI Team
+
`;
+
+ if (SENDGRID_KEY) {
+ await sgMail.send({
+ to: email,
+ from: 'no-reply@aptivaai.com',
+ subject: `You're invited to ${organizationName} on AptivaAI`,
+ text,
+ html
+ });
+ console.log(`[INVITE] Sent new user invitation email to ${email}`);
+ } else {
+ console.log(`[INVITE] SendGrid disabled. Would send new user invite to ${email}:`);
+ console.log(`[INVITE] Link: ${inviteLink}`);
+ }
+}
+
+// Send invitation to EXISTING users (already have AptivaAI account)
+async function sendExistingUserInvitation(email, firstname, organizationName, organizationId, userId) {
+ const inviteToken = jwt.sign(
+ {
+ email,
+ userId,
+ organizationId,
+ prp: 'student_invite',
+ isNewUser: false
+ },
+ JWT_SECRET,
+ { expiresIn: '7d' }
+ );
+
+ const MAIN_APP_URL = process.env.APTIVA_API_BASE || 'https://aptivaai.com';
+ const inviteLink = `${MAIN_APP_URL}/invite-response?token=${inviteToken}`;
+
+ const text = `Hi ${firstname},
+
+You've been invited to join ${organizationName} on AptivaAI!
+
+We noticed you already have an AptivaAI account. You have two options:
+
+1. Link your existing account to ${organizationName}
+ - Your existing data and progress will be preserved
+ - ${organizationName} can view your activity based on your privacy settings
+ - You'll gain premium access through ${organizationName}
+
+2. Create a separate account for ${organizationName}
+ - Keep your personal AptivaAI account separate
+ - Start fresh with a new profile for school/organization use
+
+Click the link below to choose (valid for 7 days):
+${inviteLink}
+
+Questions? Reply to this email or contact your administrator.
+
+— The AptivaAI Team`;
+
+ const html = `
+
You're Invited to Join ${organizationName}!
+
Hi ${firstname},
+
You've been invited to join ${organizationName} on AptivaAI!
+
+
+
We noticed you already have an AptivaAI account
+
+
+
You have two options:
+
+
+
Option 1: Link Your Existing Account
+
+
Your existing data and progress will be preserved
+
${organizationName} can view your activity based on your privacy settings
+
You'll gain premium access through ${organizationName}
+
+
+
+
+
Option 2: Create a Separate Account
+
+
Keep your personal AptivaAI account separate
+
Start fresh with a new profile for school/organization use
+ Configure how long after roster upload students in grades 11-12 should be prompted to complete Premium Onboarding. This delay gives students time to explore free features before creating career and college profiles.
+
+ Juniors and seniors (grades 11-12) will be prompted to complete Premium Onboarding {orgProfile.onboarding_delay_days} days after being added to your roster. Freshmen and sophomores are never prompted.
+
+
+
+ Financial information is never shared with organizations to protect student privacy.
+
+
+
+ Note: Expected salary projections from College Profiles are visible above as they are considered career planning data, not personal financial information.
+
+
+
+
+ {/* Career Roadmap */}
+
+
Career Roadmap
+ {!privacy.share_roadmap ? (
+
+
+
+ Student has not shared career roadmap with your organization.
+
+
+
+ );
+}
diff --git a/src/components/CareerExplorer.js b/src/components/CareerExplorer.js
index cffd539..c98d82c 100644
--- a/src/components/CareerExplorer.js
+++ b/src/components/CareerExplorer.js
@@ -339,6 +339,14 @@ function CareerExplorer() {
setSelectedCareer(career);
setCareerDetails(null);
+ // Track career view (fire and forget)
+ api.post('/api/track-career-view', {
+ career_soc_code: socCode,
+ career_name: career.title
+ }, {
+ withCredentials: true
+ }).catch(err => console.error('[CareerExplorer] Failed to track view:', err));
+
try {
let cipCode = null;
try { const { data } = await api.get(`/api/cip/${socCode}`); cipCode = data?.cipCode ?? null; } catch {}
diff --git a/src/components/CareerProfileForm.js b/src/components/CareerProfileForm.js
index fab2f90..1bbc44b 100644
--- a/src/components/CareerProfileForm.js
+++ b/src/components/CareerProfileForm.js
@@ -54,7 +54,7 @@ export default function CareerProfileForm() {
...prev,
scenario_title : d.scenario_title ?? '',
career_name : d.career_name ?? '',
- soc_code : d.soc_code ?? '',
+ soc_code : d.career_soc_code ?? d.soc_code ?? '',
status : d.status ?? 'current',
start_date : (d.start_date || '').slice(0, 10), // ← trim
retirement_start_date : (d.retirement_start_date || '').slice(0, 10),
@@ -78,6 +78,7 @@ export default function CareerProfileForm() {
headers : { 'Content-Type': 'application/json' },
body : JSON.stringify({
...form,
+ career_soc_code : form.soc_code, // map to backend field name
start_date : form.start_date?.slice(0, 10) || null,
retirement_start_date : form.retirement_start_date?.slice(0, 10) || null,
id: id === 'new' ? undefined : id // upsert
diff --git a/src/components/CareerRoadmap.js b/src/components/CareerRoadmap.js
index aac749d..0138a75 100644
--- a/src/components/CareerRoadmap.js
+++ b/src/components/CareerRoadmap.js
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useMemo, useCallback, useContext } from 'react';
-import { useLocation, useParams } from 'react-router-dom';
+import { useLocation, useParams, useNavigate } 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';
@@ -335,6 +335,7 @@ function getYearsInCareer(startDateString) {
export default function CareerRoadmap({ selectedCareer: initialCareer }) {
const { careerId } = useParams();
const location = useLocation();
+ const navigate = useNavigate();
const [interestStrategy, setInterestStrategy] = useState('FLAT'); // 'NONE' | 'FLAT' | 'RANDOM'
const [flatAnnualRate, setFlatAnnualRate] = useState(0.06);
@@ -864,6 +865,9 @@ useEffect(() => {
setCareerProfileId(latest.id);
setSelectedCareer(latest);
localStorage.setItem('lastSelectedCareerProfileId', latest.id);
+ } else if (!careerProfiles.length && !cancelled) {
+ // No profiles exist - redirect to billing success screen to set up premium features
+ navigate('/billing?ck=success', { replace: true });
}
})();
diff --git a/src/components/InterestInventory.js b/src/components/InterestInventory.js
index 57d15c7..29259b3 100644
--- a/src/components/InterestInventory.js
+++ b/src/components/InterestInventory.js
@@ -171,7 +171,7 @@ const InterestInventory = () => {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
- riasec_scores: scoresMap // store in DB as a JSON string
+ riasec: scoresMap // server1 expects 'riasec', not 'riasec_scores'
}),
});
diff --git a/src/components/InviteResponse.js b/src/components/InviteResponse.js
new file mode 100644
index 0000000..a0e7b8c
--- /dev/null
+++ b/src/components/InviteResponse.js
@@ -0,0 +1,235 @@
+import { useState, useEffect, useRef } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import axios from 'axios';
+
+export default function InviteResponse() {
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+ const [loading, setLoading] = useState(true);
+ const [tokenData, setTokenData] = useState(null);
+ const [error, setError] = useState(null);
+ const [processing, setProcessing] = useState(false);
+ const shouldAutoLink = useRef(false);
+
+ const handleLinkAccount = async () => {
+ setProcessing(true);
+ setError(null);
+
+ try {
+ // Try to link the account directly - if not authenticated, backend will return 401
+ await axios.post('/api/link-account', {
+ token: tokenData.token
+ }, {
+ withCredentials: true
+ });
+
+ // Force a page reload to refresh user profile and trigger privacy settings check
+ window.location.href = '/signin-landing';
+ } catch (err) {
+ // If not authenticated, redirect to signin
+ if (err.response?.status === 401 || err.response?.status === 403) {
+ const returnUrl = encodeURIComponent(`/invite-response?token=${tokenData.token}&autolink=true`);
+ navigate(`/signin?redirect=${returnUrl}`);
+ return;
+ }
+
+ setError(err.response?.data?.error || 'Failed to link account. Please try again.');
+ setProcessing(false);
+ }
+ };
+
+ useEffect(() => {
+ validateToken();
+ }, []);
+
+ // Auto-link when tokenData is set and autolink flag is true
+ useEffect(() => {
+ if (tokenData && shouldAutoLink.current && !processing) {
+ shouldAutoLink.current = false; // Prevent double-trigger
+ handleLinkAccount();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tokenData]);
+
+ const validateToken = async () => {
+ const token = searchParams.get('token');
+ const autoLink = searchParams.get('autolink'); // Check if we should auto-link after signin
+
+ if (!token) {
+ setError('No invitation token provided');
+ setLoading(false);
+ return;
+ }
+
+ try {
+ // Decode token to check if it's for existing user
+ const base64Url = token.split('.')[1];
+ const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
+ const payload = JSON.parse(window.atob(base64));
+
+ if (payload.prp !== 'student_invite') {
+ setError('Invalid invitation token');
+ setLoading(false);
+ return;
+ }
+
+ if (payload.isNewUser !== false) {
+ // This is for a new user, redirect to signup
+ navigate(`/signup?invite=${token}`, { replace: true });
+ return;
+ }
+
+ // Token is valid for existing user
+ setTokenData({
+ token,
+ organizationId: payload.organizationId,
+ email: payload.email
+ });
+ setLoading(false);
+
+ // If autolink param is present, set flag to trigger auto-link
+ if (autoLink === 'true') {
+ shouldAutoLink.current = true;
+ }
+ } catch (err) {
+ setError('Invalid invitation token');
+ setLoading(false);
+ }
+ };
+
+ const handleCreateSeparateAccount = () => {
+ // User needs to contact admin for a new invitation with different email
+ navigate('/', { replace: true });
+ };
+
+ if (loading) {
+ return (
+
+
+
+
Validating invitation...
+
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
+
+
+
+
Invalid Invitation
+
{error}
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
You're Invited!
+
Choose how you'd like to proceed
+
+
+
+
+ We noticed you already have an AptivaAI account. You can either link your existing account or create a separate one for your organization.
+
+
+
+ {error && (
+
+
{error}
+
+ )}
+
+
+ {/* Option 1: Link Existing Account */}
+
+
Option 1: Link Your Existing Account
+
+
+
+ Your existing data and progress will be preserved
+
+
+
+ Your organization can view your activity based on your privacy settings
+
+
+
+ You'll gain premium access through your organization
+
+
+
+
+
+ {/* Option 2: Create Separate Account */}
+
+
Option 2: Create a Separate Account
+
+
+
+ Keep your personal AptivaAI account completely separate
+
+
+
+ Start fresh with a new profile for school/organization use
+
+
+
+ Requires a different email address
+
+
+
+ Note: To create a separate account, you'll need to contact your administrator and provide a different email address. They can then send you a new invitation.
+
+
+
+
+
+
+ Questions? Contact your administrator for help.
+
Account information (username, password, profile details).
+
Account information (username, password, email, phone, location).
- Payment information handled by third-party payment processors
-+ (we do not store full card details).
+ Date of birth for age verification (COPPA compliance). We encrypt and
+ securely store this information for legal compliance only and never share it with third parties.
-
- Messaging information (phone/email for notifications) handled by communications providers.
-
-
Career and education data you enter into your profile.
+
Payment information (handled by third-party payment processors; we do not store full card details).
+
Career and education data you enter (career interests, goals, financial planning data).
Technical information: necessary cookies and local storage (for
authentication, security, and preferences).
@@ -90,9 +88,45 @@ function PrivacyPolicy() {
Data Sharing
- We only share information with providers required to run AptivaAI.
- These providers are bound by their
- own security/privacy obligations. We do not sell or rent your data.
+ We only share information with service providers required to run AptivaAI.
+ These providers are bound by their own security and privacy obligations.
+ We do not sell or rent your data.
+
+
+
Third-Party Service Providers
+
+ AptivaAI uses the following third-party service providers to deliver our services:
+
+
+
+ OpenAI (Career Coach chat functionality) - We use OpenAI's API
+ to power our Career Coach chat feature. Per OpenAI's data policy, API data is
+ not used to train or improve their models. Data sent via the API is retained
+ for 30 days solely for abuse and misuse monitoring, after which it is
+ permanently deleted. Student names and contact information are not sent to
+ OpenAI; only career-related data (location, career interests, goals) is shared
+ for providing coaching guidance. View{' '}
+
+ OpenAI's privacy policy
+ .
+
+
+ Payment processors - We use third-party payment processors
+ (Stripe) to handle subscription payments. We do not store full credit card details.
+
+
+ Communication providers - We use third-party services for
+ email and SMS notifications when you opt in to receive them.
+
+
+
+ We require all service providers to maintain appropriate security measures and
+ to use your information only for the purposes we specify.
Data Security
@@ -102,10 +136,23 @@ function PrivacyPolicy() {
your data.
+
Children's Privacy (COPPA)
+
+ AptivaAI is designed for users aged 13 and older. We comply with the Children's
+ Online Privacy Protection Act (COPPA). We require all users to verify they are at
+ least 13 years old during registration. We collect date of birth solely for age
+ verification and legal compliance purposes. This information is encrypted and stored
+ securely, and is never shared with third parties including educational organizations.
+ If we become aware that a user under 13 has provided us with personal information,
+ we will take steps to delete such information. If you believe your child under 13
+ has created an account, please contact us immediately at{' '}
+ .
+
+
Your Rights
- Depending on your location, you may have the right to access, correct,
- or delete your personal data; opt out of optional cookies (when added);
+ Depending on your location, you may have the right to access, correct,
+ or delete your personal data; opt out of optional cookies (when added);
or cancel your account. To exercise your rights, contact us at{' '}
.
+ When you enable these settings, your organization's counselors and administrators can view your activity
+ data tied to your name and email. This allows them to provide personalized guidance and support for your
+ career exploration. Your data is always encrypted and secure, and you can change these settings at any time.
+
+ You've enrolled in {currentOrg.organization_name}. Please choose what information you'd like to share with them.
+ {organizations.length > 1 && ` (${currentOrgIndex + 1} of ${organizations.length})`}
+
+
+
+
+ Your privacy is important to us. All settings default to private. When you enable sharing, your organization's counselors can view your activity data (tied to your name and email) to provide personalized guidance. You can change these settings anytime from Profile → Privacy Settings.
+
+ You must be at least 13 years old to use AptivaAI. We encrypt and securely store your date of birth for legal compliance only.
+
+
+
{/* ─────────────── New: Mobile number ─────────────── */}
2. Eligibility
- You must be at least 16 years old, or the minimum age required by law in your jurisdiction,
- to use AptivaAI. By using the Service, you represent that you meet this requirement.
+ You must be at least 13 years old to use AptivaAI. If you are under 18, you confirm that you have
+ your parent or guardian's permission to use the Service. By using the Service, you represent that
+ you meet this requirement.
3. Accounts
diff --git a/src/components/UserProfile.js b/src/components/UserProfile.js
index 750232a..0f4fc73 100644
--- a/src/components/UserProfile.js
+++ b/src/components/UserProfile.js
@@ -338,6 +338,26 @@ function UserProfile() {
+ {/* Date of Birth Notice */}
+
+
+
+
+
Date of Birth
+
+ Your date of birth cannot be changed as it is used for age verification and legal compliance (COPPA).
+ If you entered your date of birth incorrectly during signup, please contact{' '}
+
+ support@aptivaai.com
+ {' '}
+ with proof of age for assistance.
+