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.

+

+ + Create Your Account + +

+
+

+ Already have an AptivaAI account with a different email? +

+

+ + Click here to link your existing account + +

+
+

This invitation link is valid for 7 days.

+

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
  • +
+
+ +

+ + Choose Your Option + +

+ +

This invitation link is valid for 7 days.

+

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 existing user invitation email to ${email}`); + } else { + console.log(`[INVITE] SendGrid disabled. Would send existing user invite to ${email}:`); + console.log(`[INVITE] Link: ${inviteLink}`); + } +} + +// Legacy function - redirects to appropriate function based on isNewUser flag +async function sendStudentInvitation(email, firstname, organizationName, organizationId, userId, isNewUser = true) { + if (isNewUser) { + return sendNewUserInvitation(email, firstname, organizationName, organizationId, userId); + } else { + return sendExistingUserInvitation(email, firstname, organizationName, organizationId, userId); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// VALIDATE INVITATION TOKEN (PUBLIC ENDPOINT) +// ═══════════════════════════════════════════════════════════════════════════ + +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.execute( + 'SELECT id, email, firstname, 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.execute( + '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 for pre-fill + let email = user.email; + try { + email = decrypt(email); + } catch (err) { + // Not encrypted or decryption failed + } + + return res.json({ + valid: true, + email: email, + 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' }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// AUTH MIDDLEWARE +// ═══════════════════════════════════════════════════════════════════════════ + +async function requireAdminAuth(req, res, next) { + const token = req.cookies[ADMIN_COOKIE_NAME]; + + if (!token) { + return res.status(401).json({ error: 'Admin authentication required' }); + } + + try { + const decoded = jwt.verify(token, JWT_SECRET); + + const [admins] = await pool.execute(` + SELECT oa.id, oa.organization_id, oa.user_id, oa.role, + o.organization_name, o.organization_type + FROM organization_admins oa + JOIN organizations o ON oa.organization_id = o.id + WHERE oa.user_id = ? + LIMIT 1 + `, [decoded.userId]); + + if (!admins || admins.length === 0) { + return res.status(403).json({ error: 'Admin access not found' }); + } + + const admin = admins[0]; + + req.admin = { + userId: admin.user_id, + organizationId: admin.organization_id, + organizationName: admin.organization_name, + organizationType: admin.organization_type, + role: admin.role, + isSuperAdmin: admin.role === 'super_admin' + }; + + next(); + } catch (err) { + console.error('[requireAdminAuth] Error:', err.message); + return res.status(401).json({ error: 'Invalid or expired admin session' }); + } +} + +function requireSuperAdmin(req, res, next) { + if (!req.admin || !req.admin.isSuperAdmin) { + return res.status(403).json({ error: 'Super admin access required' }); + } + next(); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// AUTHENTICATION ENDPOINTS +// ═══════════════════════════════════════════════════════════════════════════ + +app.post('/api/admin/auth/login', adminLoginLimiter, async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username and password required' }); + } + + try { + const [authResults] = await pool.execute(` + SELECT ua.user_id, ua.hashed_password + FROM user_auth ua + WHERE ua.username = ? + LIMIT 1 + `, [username]); + + if (!authResults || authResults.length === 0) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const { user_id, hashed_password } = authResults[0]; + const isMatch = await bcrypt.compare(password, hashed_password); + + if (!isMatch) { + return res.status(401).json({ error: 'Invalid credentials' }); + } + + const [adminResults] = await pool.execute(` + SELECT oa.id, oa.organization_id, oa.role, + o.organization_name, o.organization_type + FROM organization_admins oa + JOIN organizations o ON oa.organization_id = o.id + WHERE oa.user_id = ? + LIMIT 1 + `, [user_id]); + + if (!adminResults || adminResults.length === 0) { + return res.status(403).json({ error: 'Not authorized as organization admin' }); + } + + const admin = adminResults[0]; + + const token = jwt.sign( + { userId: user_id, organizationId: admin.organization_id }, + JWT_SECRET, + { expiresIn: '8h' } + ); + + res.cookie(ADMIN_COOKIE_NAME, token, sessionCookieOptions()); + + return res.json({ + message: 'Login successful', + admin: { + organizationName: admin.organization_name, + organizationType: admin.organization_type, + role: admin.role + } + }); + } catch (err) { + console.error('[admin-login] Error:', err.message); + return res.status(500).json({ error: 'Login failed' }); + } +}); + +app.post('/api/admin/auth/logout', (req, res) => { + res.clearCookie(ADMIN_COOKIE_NAME, sessionCookieOptions()); + return res.json({ message: 'Logged out successfully' }); +}); + +app.get('/api/admin/auth/me', requireAdminAuth, (req, res) => { + return res.json({ + organizationId: req.admin.organizationId, + organizationName: req.admin.organizationName, + organizationType: req.admin.organizationType, + role: req.admin.role, + isSuperAdmin: req.admin.isSuperAdmin + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// DASHBOARD ENDPOINTS +// ═══════════════════════════════════════════════════════════════════════════ + +app.get('/api/admin/dashboard/stats', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [totalResult] = await pool.execute(` + SELECT COUNT(*) as count + FROM organization_students + WHERE organization_id = ? + AND enrollment_status NOT IN ('withdrawn', 'transferred', 'inactive') + `, [orgId]); + + const [activeResult] = await pool.execute(` + SELECT COUNT(*) as count + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + WHERE os.organization_id = ? + AND os.enrollment_status = 'active' + AND up.last_login >= DATE_SUB(NOW(), INTERVAL 30 DAY) + `, [orgId]); + + const [rosterHistory] = await pool.execute(` + SELECT submitted_at, students_added, total_roster_size + FROM roster_updates + WHERE organization_id = ? + ORDER BY submitted_at DESC + LIMIT 3 + `, [orgId]); + + const [adminCount] = await pool.execute(` + SELECT COUNT(*) as count + FROM organization_admins + WHERE organization_id = ? + `, [orgId]); + + // Get organization's state and metro area for BLS data queries + let orgState = 'US'; + let orgStateAbbr = 'US'; + let orgMetro = null; + let topPayingCareers = []; + let fastestGrowingCareers = []; + let mostJobsCareers = []; + + try { + const [orgData] = await pool.execute(` + SELECT state, metro_area FROM organizations WHERE id = ? LIMIT 1 + `, [orgId]); + + orgStateAbbr = orgData[0]?.state || 'US'; + orgState = fullStateFrom(orgStateAbbr); + orgMetro = orgData[0]?.metro_area; + + // Get highest paying careers - use projection data to get career list, then fetch salaries + const salaryLocation = orgMetro || orgState; + try { + const stateName = orgState.toLowerCase(); + + // Get all careers for this state from projections data + const stateCareers = allProjections + .filter(r => { + const area = String(r['Area Name'] || '').trim().toLowerCase(); + return area === stateName; + }) + .map(r => ({ + soc_code: r['Occupation Code'], + career_name: r['Occupation Name'] + })); + + // Deduplicate by SOC code (some may appear multiple times) + const uniqueCareers = Array.from(new Map(stateCareers.map(c => [c.soc_code, c])).values()); + + // Sample top 50 careers to check salaries (to avoid too many API calls) + const careersToCheck = uniqueCareers.slice(0, 50); + + // For each career, fetch salary data from server2 + const salaryPromises = careersToCheck.map(async (career) => { + try { + const resp = await axios.get('http://server2:5001/api/salary', { + params: { + socCode: career.soc_code, + area: salaryLocation + }, + timeout: 2000 + }); + const median = resp.data?.regional?.regional_MEDIAN || resp.data?.national?.national_MEDIAN; + if (median) { + return { + career_name: career.career_name, + median_salary: median, + soc_code: career.soc_code + }; + } + } catch (err) { + // Skip careers with no salary data + } + return null; + }); + + const salaryResults = (await Promise.all(salaryPromises)) + .filter(x => x !== null) + .sort((a, b) => b.median_salary - a.median_salary) + .slice(0, 5); + + topPayingCareers = salaryResults; + } catch (salaryErr) { + console.error('[dashboard-stats] BLS salary query failed:', salaryErr.message); + } + + // Get fastest growing careers by state (by growth rate %) + try { + const stateName = orgState.toLowerCase(); + const stateProjections = allProjections.filter(r => { + const area = String(r['Area Name'] || '').trim().toLowerCase(); + return area === stateName; + }); + + // Sort by percent change descending + const sorted = stateProjections + .map(r => ({ + career_name: r['Occupation Name'], + growth_rate: parseFloat(r['Percent Change']) || 0, + soc_code: r['Occupation Code'] + })) + .sort((a, b) => b.growth_rate - a.growth_rate) + .slice(0, 5); + + fastestGrowingCareers = sorted; + } catch (growthErr) { + console.error('[dashboard-stats] BLS projections query failed:', growthErr.message); + } + + // Get careers with most projected jobs (total job count in 10 years) + try { + const stateName = orgState.toLowerCase(); + const stateProjections = allProjections.filter(r => { + const area = String(r['Area Name'] || '').trim().toLowerCase(); + return area === stateName; + }); + + // Sort by projection (total jobs in 10 years) descending + const sorted = stateProjections + .map(r => ({ + career_name: r['Occupation Name'], + projected_jobs: parseInt(r['Projection']) || 0, + soc_code: r['Occupation Code'] + })) + .filter(x => x.projected_jobs > 0) + .sort((a, b) => b.projected_jobs - a.projected_jobs) + .slice(0, 5); + + mostJobsCareers = sorted; + } catch (jobsErr) { + console.error('[dashboard-stats] BLS jobs projections query failed:', jobsErr.message); + } + } catch (orgErr) { + console.error('[dashboard-stats] Organization query failed:', orgErr.message); + } + + // Get roster update reminders based on academic calendar + let rosterReminders = []; + try { + const [calendarData] = await pool.execute(` + SELECT calendar_type, fall_term_start_month, fall_term_start_day, fall_add_drop_deadline_days, + winter_term_start_month, winter_term_start_day, winter_add_drop_deadline_days, + spring_term_start_month, spring_term_start_day, spring_add_drop_deadline_days, + summer_term_start_month, summer_term_start_day, summer_add_drop_deadline_days + FROM academic_calendars + WHERE organization_id = ? + LIMIT 1 + `, [orgId]); + + if (calendarData && calendarData.length > 0) { + const calendar = calendarData[0]; + const today = new Date(); + const currentYear = today.getFullYear(); + + // Helper to calculate deadline date + const getDeadlineDate = (month, day, offsetDays, year = currentYear) => { + const termStart = new Date(year, month - 1, day); + const deadline = new Date(termStart); + deadline.setDate(deadline.getDate() + offsetDays); + return deadline; + }; + + // Check each term based on calendar type + const terms = []; + + // Fall term (all types) + if (calendar.fall_term_start_month) { + const fallDeadline = getDeadlineDate(calendar.fall_term_start_month, calendar.fall_term_start_day, calendar.fall_add_drop_deadline_days || 14); + terms.push({ name: 'Fall', deadline: fallDeadline }); + } + + // Winter term (quarter/trimester) + if (calendar.winter_term_start_month && ['quarter', 'trimester'].includes(calendar.calendar_type)) { + const winterDeadline = getDeadlineDate(calendar.winter_term_start_month, calendar.winter_term_start_day, calendar.winter_add_drop_deadline_days || 14); + terms.push({ name: 'Winter', deadline: winterDeadline }); + } + + // Spring term (all types) + if (calendar.spring_term_start_month) { + const springDeadline = getDeadlineDate(calendar.spring_term_start_month, calendar.spring_term_start_day, calendar.spring_add_drop_deadline_days || 14); + terms.push({ name: 'Spring', deadline: springDeadline }); + } + + // Summer term (quarter only) + if (calendar.summer_term_start_month && calendar.calendar_type === 'quarter') { + const summerDeadline = getDeadlineDate(calendar.summer_term_start_month, calendar.summer_term_start_day, calendar.summer_add_drop_deadline_days || 7); + terms.push({ name: 'Summer', deadline: summerDeadline }); + } + + // Find upcoming deadlines + for (const term of terms) { + const daysUntilDeadline = Math.ceil((term.deadline - today) / (1000 * 60 * 60 * 24)); + + if (daysUntilDeadline > 0 && daysUntilDeadline <= 30) { + rosterReminders.push({ + term: term.name, + deadline: term.deadline.toISOString().split('T')[0], + daysUntil: daysUntilDeadline, + status: 'due_soon' + }); + } else if (daysUntilDeadline < 0 && daysUntilDeadline >= -7) { + rosterReminders.push({ + term: term.name, + deadline: term.deadline.toISOString().split('T')[0], + daysUntil: Math.abs(daysUntilDeadline), + status: 'overdue' + }); + } + } + } + } catch (reminderErr) { + console.error('[dashboard-stats] Roster reminders query failed:', reminderErr.message); + } + + // Career Outcome Linkage Metrics + let careerOutcomeMetrics = { + studentsWithCareerProfiles: 0, + careerProfilesMatchingExploration: 0, + careerProfilesMatchingCareerComparison: 0, + studentsWithCollegeProfiles: 0 + }; + + try { + // Count students with career profiles + const [careerProfileCount] = await pool.execute(` + SELECT COUNT(DISTINCT cp.user_id) as count + FROM career_profiles cp + JOIN organization_students os ON cp.user_id = os.user_id + WHERE os.organization_id = ? AND os.enrollment_status = 'active' + `, [orgId]); + careerOutcomeMetrics.studentsWithCareerProfiles = careerProfileCount[0].count; + + // Count career profiles that match careers students explored (career_views) + // Match on SOC code for accuracy (career names may vary) + const [explorationMatch] = await pool.execute(` + SELECT COUNT(DISTINCT cp.user_id) as count + FROM career_profiles cp + JOIN organization_students os ON cp.user_id = os.user_id + JOIN career_views cv ON cp.user_id = cv.user_id + AND cp.career_soc_code COLLATE utf8mb4_unicode_ci = cv.career_soc_code COLLATE utf8mb4_unicode_ci + WHERE os.organization_id = ? + AND os.enrollment_status = 'active' + AND cp.career_soc_code IS NOT NULL + `, [orgId]); + careerOutcomeMetrics.careerProfilesMatchingExploration = explorationMatch[0].count; + + // Count career profiles that match careers in Career Comparison (career_list) + // Note: career_list is encrypted, must decrypt before parsing + const [studentsWithProfiles] = await pool.execute(` + SELECT DISTINCT cp.user_id, cp.career_soc_code, up.career_list + FROM career_profiles cp + JOIN organization_students os ON cp.user_id = os.user_id + JOIN user_profile up ON cp.user_id = up.id + WHERE os.organization_id = ? + AND os.enrollment_status = 'active' + AND up.career_list IS NOT NULL + AND cp.career_soc_code IS NOT NULL + `, [orgId]); + + let comparisonMatchCount = 0; + for (const row of studentsWithProfiles) { + try { + const decryptedList = decrypt(row.career_list); + const careerList = JSON.parse(decryptedList); + if (Array.isArray(careerList) && careerList.some(c => c.code === row.career_soc_code)) { + comparisonMatchCount++; + } + } catch (err) { + // Skip if decrypt/parse fails + } + } + careerOutcomeMetrics.careerProfilesMatchingCareerComparison = comparisonMatchCount; + + // Count students with college profiles + const [collegeProfileCount] = await pool.execute(` + SELECT COUNT(DISTINCT cp.user_id) as count + FROM college_profiles cp + JOIN organization_students os ON cp.user_id = os.user_id + WHERE os.organization_id = ? + AND os.enrollment_status = 'active' + `, [orgId]); + careerOutcomeMetrics.studentsWithCollegeProfiles = collegeProfileCount[0].count; + + // Note: Program alignment requires CIP-to-SOC mapping which is in server2 + // For now, just track college profile creation + + } catch (outcomeErr) { + console.error('[dashboard-stats] Career outcome metrics failed:', outcomeErr.message); + } + + return res.json({ + totalStudents: totalResult[0].count, + activeStudents: activeResult[0].count, + recentUploads: rosterHistory, + adminCount: adminCount[0].count, + topPayingCareers: topPayingCareers, + fastestGrowingCareers: fastestGrowingCareers, + mostJobsCareers: mostJobsCareers, + orgState: orgState, + orgMetro: orgMetro, + rosterReminders: rosterReminders, + careerOutcomes: careerOutcomeMetrics + }); + } catch (err) { + console.error('[dashboard-stats] Error:', err.message); + return res.status(500).json({ error: 'Failed to load dashboard stats' }); + } +}); + +// Student Career Interests with filters +app.get('/api/admin/dashboard/career-interests', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + const { signalStrength = 'viewed', timePeriod = '90', limit = '10' } = req.query; + const limitNum = Math.min(parseInt(limit) || 10, 100); // Max 100, default 10 + + // Map time period to days + const dayMap = { + '30': 30, + '90': 90, + '365': 365, + 'all': 9999 + }; + const days = dayMap[timePeriod] || 90; + + let query, params; + + // Build query based on signal strength + switch (signalStrength) { + case 'viewed': + query = ` + SELECT cv.career_name, cv.career_soc_code, COUNT(DISTINCT cv.user_id) as student_count + FROM career_views cv + JOIN organization_students os ON cv.user_id = os.user_id + WHERE os.organization_id = ? + AND os.enrollment_status = 'active' + ${days < 9999 ? 'AND cv.viewed_at >= DATE_SUB(NOW(), INTERVAL ? DAY)' : ''} + GROUP BY cv.career_soc_code, cv.career_name + ORDER BY student_count DESC + LIMIT ? + `; + params = days < 9999 ? [orgId, days, limitNum] : [orgId, limitNum]; + break; + + case 'compared': + // career_list is encrypted, must fetch and parse in app code + query = ` + SELECT up.id as user_id, up.career_list, up.updated_at + FROM user_profile up + JOIN organization_students os ON up.id = os.user_id + WHERE os.organization_id = ? + AND os.enrollment_status = 'active' + AND up.career_list IS NOT NULL + `; + params = [orgId]; + + const [rows] = await pool.execute(query, params); + const careerCounts = {}; + + for (const row of rows) { + try { + const decrypted = decrypt(row.career_list); + const careerList = JSON.parse(decrypted); + if (Array.isArray(careerList)) { + for (const career of careerList) { + const key = career.code || career.title; + if (!careerCounts[key]) { + careerCounts[key] = { + career_name: career.title, + career_soc_code: career.code, + student_count: 0 + }; + } + careerCounts[key].student_count++; + } + } + } catch (err) { + // Skip if decrypt/parse fails + } + } + + const results = Object.values(careerCounts) + .sort((a, b) => b.student_count - a.student_count) + .slice(0, limitNum); + + return res.json({ careers: results }); + + case 'profiled': + query = ` + SELECT cp.career_name, cp.career_soc_code, COUNT(DISTINCT cp.user_id) as student_count + FROM career_profiles cp + JOIN organization_students os ON cp.user_id = os.user_id + WHERE os.organization_id = ? + AND os.enrollment_status = 'active' + ${days < 9999 ? 'AND cp.created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)' : ''} + GROUP BY cp.career_soc_code, cp.career_name + ORDER BY student_count DESC + LIMIT ? + `; + params = days < 9999 ? [orgId, days, limitNum] : [orgId, limitNum]; + break; + + default: + return res.status(400).json({ error: 'Invalid signal strength' }); + } + + const [results] = await pool.execute(query, params); + return res.json({ careers: results }); + + } catch (err) { + console.error('[career-interests] Error:', err.message); + return res.status(500).json({ error: 'Failed to load career interests' }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// STUDENT MANAGEMENT ENDPOINTS +// ═══════════════════════════════════════════════════════════════════════════ + +app.get('/api/admin/students', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + const { status } = req.query; + + // Return all students - filtering/search happens on frontend after decryption + let query = ` + SELECT os.id, os.user_id, os.enrollment_status, os.enrollment_date, + os.invitation_sent_at, os.status_changed_date, os.bounce_reason, os.updated_at, + up.firstname, up.lastname, up.email, up.last_login, up.created_at + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + WHERE os.organization_id = ? + `; + const params = [orgId]; + + if (status) { + query += ` AND os.enrollment_status = ?`; + params.push(status); + } + + query += ` ORDER BY os.enrollment_date DESC`; + + const [students] = await pool.execute(query, params); + + // Add engagement metrics for each student (with guards for missing tables) + for (const student of students) { + student.inventory_completed_at = null; + student.career_profiles_count = 0; + student.college_profiles_count = 0; + student.financial_profiles_count = 0; + student.roadmaps_count = 0; + + try { + const [careerProfiles] = await pool.execute('SELECT COUNT(*) as count FROM career_profiles WHERE user_id = ?', [student.user_id]); + student.career_profiles_count = careerProfiles[0].count; + } catch (err) { + // Table doesn't exist + } + + try { + const [collegeProfiles] = await pool.execute(` + SELECT COUNT(*) as count + FROM college_profiles cp_col + JOIN career_profiles cp ON cp_col.career_profile_id = cp.id + WHERE cp.user_id = ? + `, [student.user_id]); + student.college_profiles_count = collegeProfiles[0].count; + } catch (err) { + // Table doesn't exist or no join possible + } + + try { + const [financialProfiles] = await pool.execute('SELECT COUNT(*) as count FROM financial_profiles WHERE user_id = ?', [student.user_id]); + student.financial_profiles_count = financialProfiles[0].count; + } catch (err) { + // Table doesn't exist + } + + try { + const [milestones] = await pool.execute(` + SELECT COUNT(*) as count + FROM milestones m + JOIN career_profiles cp ON m.career_profile_id = cp.id + WHERE cp.user_id = ? + `, [student.user_id]); + student.roadmaps_count = milestones[0].count; + } catch (err) { + // Table doesn't exist or no join possible + } + } + + // Decrypt all encrypted fields + for (const student of students) { + if (student.firstname) { + try { + student.firstname = decrypt(student.firstname); + } catch { + // If not encrypted or decryption fails, leave as-is + } + } + if (student.lastname) { + try { + student.lastname = decrypt(student.lastname); + } catch { + // If not encrypted or decryption fails, leave as-is + } + } + if (student.email) { + try { + student.email = decrypt(student.email); + } catch { + // If not encrypted or decryption fails, leave as-is + } + } + + // SECURITY: Remove user_id before sending to frontend + delete student.user_id; + } + + return res.json({ students }); + } catch (err) { + console.error('[students-list] Error:', err.message); + return res.status(500).json({ error: 'Failed to load students' }); + } +}); + +app.get('/api/admin/students/:studentId', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + const { studentId } = req.params; + + // studentId is now organization_students.id, not user_id + const [studentResults] = await pool.execute(` + SELECT + os.*, + up.firstname, + up.lastname, + up.email, + up.last_login, + up.created_at, + up.career_list, + up.interest_inventory_answers, + up.riasec_scores + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + WHERE os.organization_id = ? AND os.id = ? + LIMIT 1 + `, [orgId, studentId]); + + if (!studentResults || studentResults.length === 0) { + return res.status(404).json({ error: 'Student not found' }); + } + + const student = studentResults[0]; + const userId = student.user_id; // Extract for internal queries + + console.log('[student-detail] Raw data from DB:', { + userId, + career_list: student.career_list, + interest_inventory_answers: student.interest_inventory_answers + }); + + // Decrypt email + if (student.email) { + try { + student.email = decrypt(student.email); + } catch { + // Leave as-is if not encrypted + } + } + + // Check if interest inventory is completed (60-char string like "111222333...") + student.inventory_completed_at = null; + if (student.interest_inventory_answers && student.interest_inventory_answers.length === 60) { + student.inventory_completed_at = student.created_at; + } + + // Check if careers have been added to comparison list (career_list is JSON array in user_profile) + student.career_comparison_count = 0; + if (student.career_list) { + try { + const careerList = typeof student.career_list === 'string' + ? JSON.parse(student.career_list) + : student.career_list; + + student.career_comparison_count = Array.isArray(careerList) ? careerList.length : 0; + } catch { + // Invalid JSON or not an array + } + } + + // Count career profiles, college profiles, financial profiles, and roadmaps (premium features) + student.career_profiles_count = 0; + student.college_profiles_count = 0; + student.financial_profiles_count = 0; + student.roadmaps_count = 0; + + try { + const [careerProfiles] = await pool.execute('SELECT COUNT(*) as count FROM career_profiles WHERE user_id = ?', [userId]); + student.career_profiles_count = careerProfiles[0].count; + } catch (err) { + // Table doesn't exist + } + + try { + const [collegeProfiles] = await pool.execute('SELECT COUNT(*) as count FROM college_profiles WHERE user_id = ?', [userId]); + student.college_profiles_count = collegeProfiles[0].count; + } catch (err) { + // Table doesn't exist + } + + try { + const [financialProfiles] = await pool.execute('SELECT COUNT(*) as count FROM financial_profiles WHERE user_id = ?', [userId]); + student.financial_profiles_count = financialProfiles[0].count; + } catch (err) { + // Table doesn't exist + } + + try { + const [milestones] = await pool.execute(` + SELECT COUNT(*) as count + FROM milestones m + JOIN career_profiles cp ON m.career_profile_id = cp.id + WHERE cp.user_id = ? + `, [userId]); + student.roadmaps_count = milestones[0].count; + } catch (err) { + // Table doesn't exist or no join possible + } + + // Remove sensitive fields before continuing + delete student.career_list; + delete student.interest_inventory_answers; + + const [privacyResults] = await pool.execute(` + SELECT * FROM student_privacy_settings + WHERE user_id = ? AND organization_id = ? + LIMIT 1 + `, [userId, orgId]); + + // Default to all false (private by default) if no settings exist + const privacy = privacyResults[0] || { + share_career_exploration: false, + share_interest_inventory: false, + share_career_profiles: false, + share_college_profiles: false, + share_financial_profile: false, + share_roadmap: false + }; + + let careers = []; + if (privacy.share_career_exploration) { + const [careerResults] = await pool.execute(` + SELECT career_name, career_soc_code, COUNT(*) as view_count, MAX(viewed_at) as last_viewed + FROM career_views + WHERE user_id = ? + GROUP BY career_soc_code, career_name + ORDER BY last_viewed DESC + LIMIT 10 + `, [userId]); + careers = careerResults; + } + + // Fetch RIASEC scores if privacy allows + let riasecScores = null; + console.log('[student-detail] RIASEC check:', { + share_interest_inventory: privacy.share_interest_inventory, + has_riasec_scores: !!student.riasec_scores, + riasec_scores_value: student.riasec_scores + }); + if (privacy.share_interest_inventory && student.riasec_scores) { + try { + const decrypted = decrypt(student.riasec_scores); + const parsed = JSON.parse(decrypted); + console.log('[student-detail] RIASEC parsed:', parsed); + // Convert {"R":23,"I":25,"A":23,"S":16,"E":15,"C":22} to array format for chart + riasecScores = [ + { area: 'Realistic', score: parsed.R || 0 }, + { area: 'Investigative', score: parsed.I || 0 }, + { area: 'Artistic', score: parsed.A || 0 }, + { area: 'Social', score: parsed.S || 0 }, + { area: 'Enterprising', score: parsed.E || 0 }, + { area: 'Conventional', score: parsed.C || 0 } + ]; + } catch (err) { + console.error('[student-detail] RIASEC error:', err.message); + } + } + delete student.riasec_scores; + + // Fetch career profiles if privacy allows + let careerProfiles = []; + if (privacy.share_career_profiles) { + try { + const [profiles] = await pool.execute(` + SELECT id, career_name, status, start_date, retirement_start_date, + college_enrollment_status, currently_working, scenario_title, created_at + FROM career_profiles + WHERE user_id = ? + ORDER BY created_at DESC + `, [userId]); + console.log('[student-detail] Career profiles found:', profiles.length); + careerProfiles = profiles; + } catch (err) { + console.error('[student-detail] Career profiles error:', err.message); + } + } + + // Fetch college profiles if privacy allows + let collegeProfiles = []; + if (privacy.share_college_profiles) { + try { + const [profiles] = await pool.execute(` + SELECT id, selected_school, selected_program, program_type, + college_enrollment_status, tuition, expected_graduation, expected_salary, created_at + FROM college_profiles + WHERE user_id = ? + ORDER BY created_at DESC + `, [userId]); + console.log('[student-detail] College profiles found:', profiles.length); + collegeProfiles = profiles; + } catch (err) { + console.error('[student-detail] College profiles error:', err.message); + } + } + + // Fetch career roadmap milestones if privacy allows + let roadmapMilestones = []; + if (privacy.share_roadmap) { + try { + const [milestones] = await pool.execute(` + SELECT m.id, m.title, m.description, m.date, m.status, cp.career_name + FROM milestones m + JOIN career_profiles cp ON m.career_profile_id = cp.id + WHERE cp.user_id = ? + ORDER BY m.date ASC + `, [userId]); + console.log('[student-detail] Roadmap milestones found:', milestones.length); + roadmapMilestones = milestones; + } catch (err) { + console.error('[student-detail] Roadmap milestones error:', err.message); + } + } + + // SECURITY: Remove user_id before sending to frontend + delete student.user_id; + if (privacy.user_id) delete privacy.user_id; + + return res.json({ + student, + privacy, + careers, + riasecScores, + careerProfiles, + collegeProfiles, + roadmapMilestones + }); + } catch (err) { + console.error('[student-detail] Error:', err.message); + return res.status(500).json({ error: 'Failed to load student details' }); + } +}); + +// Reset password for student +app.post('/api/admin/students/:studentId/reset-password', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + const { studentId } = req.params; + const db = pool.raw || pool; + + // Verify student belongs to this org (studentId is organization_students.id) + const [studentCheck] = await pool.execute(` + SELECT os.*, up.email + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + WHERE os.organization_id = ? AND os.id = ? + LIMIT 1 + `, [orgId, studentId]); + + if (!studentCheck || studentCheck.length === 0) { + return res.status(404).json({ error: 'Student not found' }); + } + + const student = studentCheck[0]; + + // Decrypt email + let email = student.email; + try { + email = decrypt(email); + } catch (err) { + // Email might not be encrypted + } + + const emailNorm = String(email).trim().toLowerCase(); + + // Generate reset token + const token = crypto.randomBytes(32).toString('hex'); + const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); + const now = Date.now(); + const RESET_TTL_MIN = 60; + const expiresAt = now + RESET_TTL_MIN * 60 * 1000; + + // Store reset token in password_resets table + await db.query( + `INSERT INTO password_resets (email, token_hash, expires_at, created_at, ip) + VALUES (?, ?, ?, ?, ?)`, + [emailNorm, tokenHash, expiresAt, now, req.ip || 'admin-portal'] + ); + + // Build reset link (using student portal URL) + const baseUrl = (process.env.APTIVA_API_BASE || '').replace(/\/+$/, ''); + const resetLink = `${baseUrl}/reset-password/${token}`; + + const emailText = +`Your administrator has requested a password reset for your Aptiva account. + +Use the link below to reset your password (valid for ${RESET_TTL_MIN} minutes): +${resetLink} + +If you didn't request this, you can ignore this email.`; + + // Send email via SendGrid or log in dev mode + if (SENDGRID_KEY) { + await sgMail.send({ + to: emailNorm, + from: 'no-reply@aptivaai.com', + subject: 'Reset your Aptiva password', + text: emailText, + html: `
${emailText}
` + }); + console.log(`[reset-password] Sent reset email to ${emailNorm}`); + } else { + console.log(`[DEV] Password reset link for ${emailNorm}: ${resetLink}`); + } + + return res.json({ message: 'Password reset email sent' }); + } catch (err) { + console.error('[reset-password] Error:', err.message); + return res.status(500).json({ error: 'Failed to send password reset' }); + } +}); + +// Deactivate student account +app.post('/api/admin/students/:studentId/deactivate', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + const { studentId } = req.params; + const { status } = req.body; + + // Validate status + const validStatuses = ['graduated', 'withdrawn', 'transferred', 'inactive']; + const newStatus = validStatuses.includes(status) ? status : 'inactive'; + + // Update student enrollment status (studentId is organization_students.id) + const [result] = await pool.execute(` + UPDATE organization_students + SET enrollment_status = ?, + status_changed_date = NOW() + WHERE organization_id = ? AND id = ? + `, [newStatus, orgId, studentId]); + + if (result.affectedRows === 0) { + return res.status(404).json({ error: 'Student not found' }); + } + + console.log(`[deactivate-student] Student ${studentId} marked as ${newStatus}`); + return res.json({ message: `Student marked as ${newStatus}` }); + } catch (err) { + console.error('[deactivate-student] Error:', err.message); + return res.status(500).json({ error: 'Failed to update student status' }); + } +}); + +// Resend invitation to single student +app.post('/api/admin/students/:studentId/resend-invitation', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + const { studentId } = req.params; + + // Get student info (studentId is organization_students.id) + const [students] = await pool.execute(` + SELECT os.*, up.email, up.firstname, up.lastname + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + WHERE os.organization_id = ? AND os.id = ? + LIMIT 1 + `, [orgId, studentId]); + + if (!students || students.length === 0) { + return res.status(404).json({ error: 'Student not found' }); + } + + const student = students[0]; + const userId = student.user_id; + + // Decrypt fields + let email = student.email; + let firstname = student.firstname; + try { + email = decrypt(email); + firstname = decrypt(firstname); + } catch (err) { + // Fields might not be encrypted + } + + // Update invitation_sent_at + await pool.execute(` + UPDATE organization_students + SET invitation_sent_at = NOW() + WHERE organization_id = ? AND id = ? + `, [orgId, studentId]); + + // Send invitation email + sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId).catch(emailErr => { + console.error(`[resend-invitation] Email send failed for ${email}:`, emailErr.message); + }); + + return res.json({ message: 'Invitation resent' }); + } catch (err) { + console.error('[resend-invitation] Error:', err.message); + return res.status(500).json({ error: 'Failed to resend invitation' }); + } +}); + +// Resend all pending invitations +app.post('/api/admin/students/resend-all-invitations', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + // Get all pending invitation students + const [students] = await pool.execute(` + SELECT os.user_id, up.email, up.firstname + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + WHERE os.organization_id = ? AND os.enrollment_status = 'pending_invitation' + `, [orgId]); + + if (!students || students.length === 0) { + return res.json({ message: 'No pending invitations to resend' }); + } + + // Update all invitation_sent_at + await pool.execute(` + UPDATE organization_students + SET invitation_sent_at = NOW() + WHERE organization_id = ? AND enrollment_status = 'pending_invitation' + `, [orgId]); + + // Send emails to all + for (const student of students) { + let email = student.email; + let firstname = student.firstname; + const userId = student.user_id; + try { + email = decrypt(email); + firstname = decrypt(firstname); + } catch (err) { + // Not encrypted + } + + sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId).catch(emailErr => { + console.error(`[resend-all] Email send failed for ${email}:`, emailErr.message); + }); + } + + return res.json({ message: `Invitations resent to ${students.length} students` }); + } catch (err) { + console.error('[resend-all] Error:', err.message); + return res.status(500).json({ error: 'Failed to resend invitations' }); + } +}); + +// Update email for bounced invitation +app.post('/api/admin/students/:studentId/update-email', requireAdminAuth, async (req, res) => { + const { email } = req.body; + + if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return res.status(400).json({ error: 'Valid email required' }); + } + + try { + const orgId = req.admin.organizationId; + const { studentId } = req.params; + + // Get student info (studentId is organization_students.id) + const [students] = await pool.execute(` + SELECT os.*, up.firstname + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + WHERE os.organization_id = ? AND os.id = ? + LIMIT 1 + `, [orgId, studentId]); + + if (!students || students.length === 0) { + return res.status(404).json({ error: 'Student not found' }); + } + + const student = students[0]; + const userId = student.user_id; // Extract for user_profile update + + // Decrypt firstname + let firstname = student.firstname; + try { + firstname = decrypt(firstname); + } catch (err) { + // Not encrypted + } + + // Encrypt new email + const emailNorm = email.toLowerCase().trim(); + const encEmail = encrypt(emailNorm); + const emailLookup = generateHMAC(emailNorm); + + // Update email in user_profile + await pool.execute(` + UPDATE user_profile + SET email = ?, email_lookup = ? + WHERE id = ? + `, [encEmail, emailLookup, userId]); + + // Update organization_students status back to pending_invitation + await pool.execute(` + UPDATE organization_students + SET enrollment_status = 'pending_invitation', + invitation_sent_at = NOW(), + bounce_reason = NULL + WHERE organization_id = ? AND id = ? + `, [orgId, studentId]); + + // Send invitation email + sendStudentInvitation(emailNorm, firstname, req.admin.organizationName, orgId, userId).catch(emailErr => { + console.error(`[update-email] Email send failed for ${emailNorm}:`, emailErr.message); + }); + + return res.json({ message: 'Email updated and invitation resent' }); + } catch (err) { + console.error('[update-email] Error:', err.message); + return res.status(500).json({ error: 'Failed to update email' }); + } +}); + +app.post('/api/admin/students', requireAdminAuth, async (req, res) => { + const { email, firstname, lastname, grade_level } = req.body; + + if (!email || !firstname || !lastname) { + return res.status(400).json({ error: 'Email, firstname, and lastname required' }); + } + + // Basic email validation + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + const orgId = req.admin.organizationId; + + // Get organization state, area, type, and onboarding delay setting + const [orgs] = await conn.execute( + 'SELECT state, city, organization_type, onboarding_delay_days FROM organizations WHERE id = ? LIMIT 1', + [orgId] + ); + const orgState = orgs[0]?.state || ''; + const orgArea = orgs[0]?.city || ''; + const orgType = orgs[0]?.organization_type || ''; + const onboardingDelayDays = orgs[0]?.onboarding_delay_days || 14; // Default 14 days + + // Email lookup (HMAC) + const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; + const emailLookup = crypto + .createHmac('sha256', EMAIL_INDEX_KEY) + .update(String(email).trim().toLowerCase()) + .digest('hex'); + + const [existingUsers] = await conn.execute( + 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1', + [emailLookup] + ); + + let userId; + + if (existingUsers.length > 0) { + userId = existingUsers[0].id; + + const [existingEnrollment] = await conn.execute( + 'SELECT id FROM organization_students WHERE organization_id = ? AND user_id = ?', + [orgId, userId] + ); + + if (existingEnrollment.length > 0) { + await conn.rollback(); + return res.status(409).json({ error: 'Student already enrolled in organization' }); + } + } else { + // Encrypt email + const emailNorm = String(email).trim().toLowerCase(); + const encEmail = encrypt(emailNorm); + + const [userResult] = await conn.execute( + 'INSERT INTO user_profile (email, email_lookup, firstname, lastname, state, area, is_premium, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, NOW())', + [encEmail, emailLookup, firstname, lastname, orgState, orgArea] + ); + userId = userResult.insertId; + } + + // Calculate onboarding trigger date + // K-12 Schools: delay for grades 11-12 only + // Other institutions: trigger immediately for all students + let triggerDate = null; + if (orgType === 'K-12 School') { + const shouldTriggerOnboarding = grade_level >= 11 && grade_level <= 12; + triggerDate = shouldTriggerOnboarding ? new Date(Date.now() + onboardingDelayDays * 24 * 60 * 60 * 1000) : null; + } else { + // Non-K12: trigger immediately + triggerDate = new Date(); + } + + await conn.execute( + 'INSERT INTO organization_students (organization_id, user_id, enrollment_status, enrollment_date, grade_level, onboarding_triggered_at) VALUES (?, ?, ?, NOW(), ?, ?)', + [orgId, userId, 'active', grade_level || null, triggerDate] + ); + + // Do NOT insert privacy settings - let modal prompt user to configure on first login + + await conn.commit(); + + // Send invitation email (async, don't block response) + try { + await sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId); + } catch (emailErr) { + console.error('[add-student] Email send failed:', emailErr.message); + // Don't fail the request if email fails + } + + return res.status(201).json({ + message: 'Student added successfully', + userId, + email + }); + } catch (err) { + await conn.rollback(); + console.error('[add-student] Error:', err.message); + if (err.stack) console.error(err.stack); + return res.status(500).json({ error: 'Failed to add student' }); + } finally { + conn.release(); + } +}); + +app.post('/api/admin/students/bulk', requireAdminAuth, bulkUploadLimiter, async (req, res) => { + const { students } = req.body; + + if (!Array.isArray(students) || students.length === 0) { + return res.status(400).json({ error: 'Students array required' }); + } + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + const orgId = req.admin.organizationId; + const results = { + added: 0, + updated: 0, + skipped: 0, + errors: [] + }; + + // Get organization state, area, type, and onboarding delay setting + const [orgs] = await conn.execute( + 'SELECT state, city, organization_type, onboarding_delay_days FROM organizations WHERE id = ? LIMIT 1', + [orgId] + ); + const orgState = orgs[0]?.state || ''; + const orgArea = orgs[0]?.city || ''; + const orgType = orgs[0]?.organization_type || ''; + const onboardingDelayDays = orgs[0]?.onboarding_delay_days || 14; // Default 14 days + + const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; + + for (const student of students) { + const { email, firstname, lastname, grade_level } = student; + + if (!email || !firstname || !lastname) { + results.errors.push({ email, error: 'Missing required fields' }); + continue; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + results.errors.push({ email, error: 'Invalid email format' }); + continue; + } + + try { + const emailNorm = String(email).trim().toLowerCase(); + const emailLookup = crypto + .createHmac('sha256', EMAIL_INDEX_KEY) + .update(emailNorm) + .digest('hex'); + + const [existingUsers] = await conn.execute( + 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1', + [emailLookup] + ); + + let userId; + + if (existingUsers.length > 0) { + userId = existingUsers[0].id; + + const [existingEnrollment] = await conn.execute( + 'SELECT id FROM organization_students WHERE organization_id = ? AND user_id = ?', + [orgId, userId] + ); + + if (existingEnrollment.length > 0) { + results.skipped++; + continue; + } + } else { + const encEmail = encrypt(emailNorm); + const encFirstname = encrypt(firstname); + const encLastname = encrypt(lastname); + const [userResult] = await conn.execute( + 'INSERT INTO user_profile (email, email_lookup, firstname, lastname, state, area, is_premium, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, NOW())', + [encEmail, emailLookup, encFirstname, encLastname, orgState, orgArea] + ); + userId = userResult.insertId; + } + + // Calculate onboarding trigger date + // K-12 Schools: delay for grades 11-12 only + // Other institutions: trigger immediately for all students + let triggerDate = null; + if (orgType === 'K-12 School') { + const shouldTriggerOnboarding = grade_level >= 11 && grade_level <= 12; + triggerDate = shouldTriggerOnboarding ? new Date(Date.now() + onboardingDelayDays * 24 * 60 * 60 * 1000) : null; + } else { + // Non-K12: trigger immediately + triggerDate = new Date(); + } + + await conn.execute( + 'INSERT INTO organization_students (organization_id, user_id, enrollment_status, enrollment_date, grade_level, onboarding_triggered_at) VALUES (?, ?, ?, NOW(), ?, ?)', + [orgId, userId, 'active', grade_level || null, triggerDate] + ); + + // Do NOT insert privacy settings - let modal prompt user to configure on first login + + results.added++; + + // Send invitation email (async, don't block loop) + sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId).catch(emailErr => { + console.error(`[bulk-upload] Email send failed for ${email}:`, emailErr.message); + }); + } catch (err) { + results.errors.push({ email, error: err.message }); + } + } + + await conn.commit(); + + return res.json({ + message: 'Bulk upload completed', + results + }); + } catch (err) { + await conn.rollback(); + console.error('[bulk-upload] Error:', err.message); + if (err.stack) console.error(err.stack); + return res.status(500).json({ error: 'Bulk upload failed' }); + } finally { + conn.release(); + } +}); + +app.patch('/api/admin/students/:studentId/status', requireAdminAuth, async (req, res) => { + const { studentId } = req.params; + const { status } = req.body; + + const validStatuses = ['active', 'graduated', 'withdrawn', 'transferred']; + if (!validStatuses.includes(status)) { + return res.status(400).json({ error: 'Invalid status. Must be: active, graduated, withdrawn, transferred' }); + } + + try { + const orgId = req.admin.organizationId; + + // studentId is organization_students.id + const [existing] = await pool.execute( + 'SELECT id FROM organization_students WHERE organization_id = ? AND id = ?', + [orgId, studentId] + ); + + if (existing.length === 0) { + return res.status(404).json({ error: 'Student not found in organization' }); + } + + await pool.execute( + 'UPDATE organization_students SET enrollment_status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ?', + [status, orgId, studentId] + ); + + return res.json({ message: 'Student status updated', status }); + } catch (err) { + console.error('[update-status] Error:', err.message); + return res.status(500).json({ error: 'Failed to update student status' }); + } +}); + +app.delete('/api/admin/students/:studentId', requireAdminAuth, requireSuperAdmin, async (req, res) => { + const { studentId } = req.params; + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + const orgId = req.admin.organizationId; + + // studentId is organization_students.id - get user_id for related deletes + const [existing] = await conn.execute( + 'SELECT id, user_id FROM organization_students WHERE organization_id = ? AND id = ?', + [orgId, studentId] + ); + + if (existing.length === 0) { + await conn.rollback(); + return res.status(404).json({ error: 'Student not found in organization' }); + } + + const userId = existing[0].user_id; + + await conn.execute( + 'DELETE FROM organization_students WHERE organization_id = ? AND id = ?', + [orgId, studentId] + ); + + await conn.execute( + 'DELETE FROM student_privacy_settings WHERE organization_id = ? AND user_id = ?', + [orgId, userId] + ); + + await conn.execute( + 'DELETE FROM user_emails WHERE organization_id = ? AND user_id = ?', + [orgId, userId] + ); + + await conn.commit(); + + return res.json({ message: 'Student removed from organization' }); + } catch (err) { + await conn.rollback(); + console.error('[delete-student] Error:', err.message); + if (err.stack) console.error(err.stack); + return res.status(500).json({ error: 'Failed to remove student' }); + } finally { + conn.release(); + } +}); + +app.get('/api/admin/students/pending', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [pending] = await pool.execute(` + SELECT ue.email, ue.email_status, ue.sent_at, ue.bounced_at, + up.firstname, up.lastname, up.created_at + FROM user_emails ue + JOIN user_profile up ON ue.user_id = up.id + WHERE ue.organization_id = ? AND ue.email_status IN ('pending', 'bounced') + ORDER BY ue.created_at DESC + `, [orgId]); + + return res.json({ pending }); + } catch (err) { + console.error('[pending-invitations] Error:', err.message); + return res.status(500).json({ error: 'Failed to load pending invitations' }); + } +}); + +app.post('/api/admin/students/resend-invitation', requireAdminAuth, resendLimiter, async (req, res) => { + const { userId } = req.body; + + if (!userId) { + return res.status(400).json({ error: 'userId required' }); + } + + try { + const orgId = req.admin.organizationId; + + const [result] = await pool.execute( + 'UPDATE user_emails SET email_status = ?, sent_at = NOW() WHERE organization_id = ? AND user_id = ?', + ['pending', orgId, userId] + ); + + if (result.affectedRows === 0) { + return res.status(404).json({ error: 'Email record not found' }); + } + + // TODO: Trigger actual email send via SendGrid/Twilio + + return res.json({ message: 'Invitation resent' }); + } catch (err) { + console.error('[resend-invitation] Error:', err.message); + return res.status(500).json({ error: 'Failed to resend invitation' }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// ORGANIZATION SETTINGS ENDPOINTS (Super Admin Only) +// ═══════════════════════════════════════════════════════════════════════════ + +app.get('/api/admin/organization/profile', requireAdminAuth, requireSuperAdmin, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [orgs] = await pool.execute( + 'SELECT * FROM organizations WHERE id = ? LIMIT 1', + [orgId] + ); + + // If organization doesn't exist, return defaults + if (orgs.length === 0) { + return res.json({ + organization_name: req.admin.organizationName || '', + organization_type: '', + address: '', + city: '', + state: '', + zip_code: '', + primary_contact_name: '', + primary_contact_email: '', + primary_contact_phone: '' + }); + } + + return res.json(orgs[0]); + } catch (err) { + console.error('[get-organization] Error:', err.message); + return res.status(500).json({ error: 'Failed to load organization' }); + } +}); + +app.put('/api/admin/organization/profile', requireAdminAuth, requireSuperAdmin, async (req, res) => { + const allowedFields = [ + 'organization_name', 'organization_type', 'address', 'city', 'state', 'zip_code', + 'primary_contact_name', 'primary_contact_email', 'primary_contact_phone', + 'onboarding_delay_days' + ]; + + const updates = {}; + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + updates[field] = req.body[field]; + } + } + + if (Object.keys(updates).length === 0) { + return res.status(400).json({ error: 'No valid fields to update' }); + } + + try { + const orgId = req.admin.organizationId; + + // Check if organization exists + const [existing] = await pool.execute( + 'SELECT id FROM organizations WHERE id = ? LIMIT 1', + [orgId] + ); + + if (existing.length === 0) { + // Create organization if it doesn't exist - ensure required fields have defaults + const defaults = { + organization_name: req.admin.organizationName || 'Unnamed Organization', + organization_type: 'Other', + state: null, + ...updates + }; + + const fields = Object.keys(defaults); + const placeholders = fields.map(() => '?').join(', '); + const values = Object.values(defaults); + + await pool.execute( + `INSERT INTO organizations (id, ${fields.join(', ')}, created_at) VALUES (?, ${placeholders}, NOW())`, + [orgId, ...values] + ); + } else { + // Update existing organization + const setClause = Object.keys(updates).map(k => `${k} = ?`).join(', '); + const values = [...Object.values(updates), orgId]; + + await pool.execute( + `UPDATE organizations SET ${setClause}, updated_at = NOW() WHERE id = ?`, + values + ); + } + + return res.json({ message: 'Organization profile updated' }); + } catch (err) { + console.error('[update-organization] Error:', err.message); + return res.status(500).json({ error: 'Failed to update organization' }); + } +}); + +app.get('/api/admin/organization/calendar', requireAdminAuth, requireSuperAdmin, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [calendars] = await pool.execute( + 'SELECT * FROM academic_calendars WHERE organization_id = ? LIMIT 1', + [orgId] + ); + + // Return defaults if no calendar exists + if (calendars.length === 0) { + return res.json({ + fall_roster_window_start: '', + fall_roster_window_end: '', + spring_roster_window_start: '', + spring_roster_window_end: '', + roster_change_threshold: 30 + }); + } + + return res.json(calendars[0]); + } catch (err) { + console.error('[get-calendar] Error:', err.message); + return res.status(500).json({ error: 'Failed to load calendar' }); + } +}); + +app.put('/api/admin/organization/calendar', requireAdminAuth, requireSuperAdmin, async (req, res) => { + const { + fall_roster_window_start, + fall_roster_window_end, + spring_roster_window_start, + spring_roster_window_end, + roster_change_threshold + } = req.body; + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + const orgId = req.admin.organizationId; + + const [existing] = await conn.execute( + 'SELECT id FROM academic_calendars WHERE organization_id = ? LIMIT 1', + [orgId] + ); + + if (existing.length === 0) { + // Create new calendar + await conn.execute( + `INSERT INTO academic_calendars ( + organization_id, + fall_roster_window_start, + fall_roster_window_end, + spring_roster_window_start, + spring_roster_window_end, + roster_change_threshold, + created_at + ) VALUES (?, ?, ?, ?, ?, ?, NOW())`, + [ + orgId, + fall_roster_window_start || null, + fall_roster_window_end || null, + spring_roster_window_start || null, + spring_roster_window_end || null, + roster_change_threshold || 30 + ] + ); + } else { + // Update existing calendar + await conn.execute( + `UPDATE academic_calendars SET + fall_roster_window_start = ?, + fall_roster_window_end = ?, + spring_roster_window_start = ?, + spring_roster_window_end = ?, + roster_change_threshold = ?, + updated_at = NOW() + WHERE organization_id = ?`, + [ + fall_roster_window_start || null, + fall_roster_window_end || null, + spring_roster_window_start || null, + spring_roster_window_end || null, + roster_change_threshold || 30, + orgId + ] + ); + } + + await conn.commit(); + + return res.json({ message: 'Calendar updated' }); + } catch (err) { + await conn.rollback(); + console.error('[update-calendar] Error:', err.message); + if (err.stack) console.error(err.stack); + return res.status(500).json({ error: 'Failed to update calendar' }); + } finally { + conn.release(); + } +}); + +app.get('/api/admin/organization/admins', requireAdminAuth, requireSuperAdmin, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [admins] = await pool.execute(` + SELECT oa.id, oa.user_id, oa.role, oa.created_at, + up.firstname, up.lastname, up.email, up.last_login + FROM organization_admins oa + JOIN user_profile up ON oa.user_id = up.id + WHERE oa.organization_id = ? + ORDER BY oa.created_at DESC + `, [orgId]); + + // Decrypt emails + for (const admin of admins) { + if (admin.email) { + try { + admin.email = decrypt(admin.email); + } catch { + // Leave as-is if not encrypted + } + } + + // SECURITY: Remove user_id before sending to frontend + delete admin.user_id; + } + + return res.json(admins); + } catch (err) { + console.error('[list-admins] Error:', err.message); + if (err.stack) console.error(err.stack); + return res.status(500).json({ error: 'Failed to load admins' }); + } +}); + +app.post('/api/admin/organization/admins', requireAdminAuth, requireSuperAdmin, async (req, res) => { + const { email, firstname, lastname, role = 'staff_admin' } = req.body; + + if (!email || !firstname || !lastname) { + return res.status(400).json({ error: 'Email, firstname, and lastname required' }); + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return res.status(400).json({ error: 'Invalid email format' }); + } + + // Schools can only create staff_admin - super_admin is created manually by Aptiva + if (role === 'super_admin') { + return res.status(403).json({ error: 'Cannot create Super Admin users. Contact AptivaAI support for Super Admin access.' }); + } + + if (!['staff_admin'].includes(role)) { + return res.status(400).json({ error: 'Invalid role. Must be: staff_admin' }); + } + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + const orgId = req.admin.organizationId; + + const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; + const emailNorm = String(email).trim().toLowerCase(); + const emailLookup = crypto + .createHmac('sha256', EMAIL_INDEX_KEY) + .update(emailNorm) + .digest('hex'); + + const [existingUsers] = await conn.execute( + 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1', + [emailLookup] + ); + + let userId; + + if (existingUsers.length > 0) { + userId = existingUsers[0].id; + + const [existingAdmin] = await conn.execute( + 'SELECT id FROM organization_admins WHERE organization_id = ? AND user_id = ?', + [orgId, userId] + ); + + if (existingAdmin.length > 0) { + await conn.rollback(); + return res.status(409).json({ error: 'User is already an admin for this organization' }); + } + } else { + const encEmail = encrypt(emailNorm); + const [userResult] = await conn.execute( + 'INSERT INTO user_profile (email, email_lookup, firstname, lastname, created_at) VALUES (?, ?, ?, ?, NOW())', + [encEmail, emailLookup, firstname, lastname] + ); + userId = userResult.insertId; + } + + await conn.execute( + 'INSERT INTO organization_admins (organization_id, user_id, role) VALUES (?, ?, ?)', + [orgId, userId, role] + ); + + await conn.commit(); + + return res.status(201).json({ + message: 'Admin added successfully', + userId, + email, + role + }); + } catch (err) { + await conn.rollback(); + console.error('[add-admin] Error:', err.message); + if (err.stack) console.error(err.stack); + return res.status(500).json({ error: 'Failed to add admin' }); + } finally { + conn.release(); + } +}); + +app.patch('/api/admin/organization/admins/:adminId', requireAdminAuth, requireSuperAdmin, async (req, res) => { + const { adminId } = req.params; + const { role } = req.body; + + // Schools can only assign staff_admin role - super_admin is managed by Aptiva + if (role === 'super_admin') { + return res.status(403).json({ error: 'Cannot assign Super Admin role. Contact AptivaAI support for Super Admin access.' }); + } + + if (!['staff_admin'].includes(role)) { + return res.status(400).json({ error: 'Invalid role. Must be: staff_admin' }); + } + + try { + const orgId = req.admin.organizationId; + + const [currentAdmin] = await pool.execute( + 'SELECT user_id FROM organization_admins WHERE id = ? AND organization_id = ?', + [adminId, orgId] + ); + + if (currentAdmin.length === 0) { + return res.status(404).json({ error: 'Admin not found' }); + } + + if (currentAdmin[0].user_id === req.admin.userId) { + return res.status(403).json({ error: 'Cannot change your own role' }); + } + + await pool.execute( + 'UPDATE organization_admins SET role = ?, updated_at = NOW() WHERE id = ? AND organization_id = ?', + [role, adminId, orgId] + ); + + return res.json({ message: 'Admin role updated', role }); + } catch (err) { + console.error('[update-admin-role] Error:', err.message); + return res.status(500).json({ error: 'Failed to update admin role' }); + } +}); + +app.delete('/api/admin/organization/admins/:adminId', requireAdminAuth, requireSuperAdmin, async (req, res) => { + const { adminId } = req.params; + + try { + const orgId = req.admin.organizationId; + + const [admin] = await pool.execute( + 'SELECT user_id FROM organization_admins WHERE id = ? AND organization_id = ?', + [adminId, orgId] + ); + + if (admin.length === 0) { + return res.status(404).json({ error: 'Admin not found' }); + } + + if (admin[0].user_id === req.admin.userId) { + return res.status(403).json({ error: 'Cannot remove yourself as admin' }); + } + + const [superAdmins] = await pool.execute( + 'SELECT COUNT(*) as count FROM organization_admins WHERE organization_id = ? AND role = ?', + [orgId, 'super_admin'] + ); + + const [removingAdmin] = await pool.execute( + 'SELECT role FROM organization_admins WHERE id = ?', + [adminId] + ); + + if (superAdmins[0].count === 1 && removingAdmin[0].role === 'super_admin') { + return res.status(403).json({ error: 'Cannot remove the last super admin' }); + } + + await pool.execute( + 'DELETE FROM organization_admins WHERE id = ? AND organization_id = ?', + [adminId, orgId] + ); + + return res.json({ message: 'Admin removed successfully' }); + } catch (err) { + console.error('[remove-admin] Error:', err.message); + return res.status(500).json({ error: 'Failed to remove admin' }); + } +}); + +app.get('/api/admin/organization/billing', requireAdminAuth, requireSuperAdmin, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [billing] = await pool.execute( + 'SELECT * FROM organization_billing WHERE organization_id = ? ORDER BY created_at DESC', + [orgId] + ); + + return res.json({ billing }); + } catch (err) { + console.error('[get-billing] Error:', err.message); + return res.status(500).json({ error: 'Failed to load billing information' }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// ROSTER UPDATE ENDPOINTS +// ═══════════════════════════════════════════════════════════════════════════ + +app.post('/api/admin/roster/upload', requireAdminAuth, rosterUploadLimiter, async (req, res) => { + const { students } = req.body; + + if (!Array.isArray(students) || students.length === 0) { + return res.status(400).json({ error: 'Students array required' }); + } + + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + + const orgId = req.admin.organizationId; + const submittedBy = req.admin.userId; + + const [currentCount] = await conn.execute( + 'SELECT COUNT(*) as count FROM organization_students WHERE organization_id = ? AND enrollment_status = ?', + [orgId, 'active'] + ); + + const previousCount = currentCount[0].count; + + const results = { added: 0, updated: 0, skipped: 0, errors: [] }; + + // Get organization state, area, type, and onboarding delay setting + const [orgs] = await conn.execute( + 'SELECT state, city, organization_type, onboarding_delay_days FROM organizations WHERE id = ? LIMIT 1', + [orgId] + ); + const orgState = orgs[0]?.state || ''; + const orgArea = orgs[0]?.city || ''; + const orgType = orgs[0]?.organization_type || ''; + const onboardingDelayDays = orgs[0]?.onboarding_delay_days || 14; // Default 14 days + + const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; + + for (const student of students) { + const { email, firstname, lastname, status = 'active', grade_level } = student; + + if (!email || !firstname || !lastname) { + results.errors.push({ email, error: 'Missing required fields' }); + continue; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + results.errors.push({ email, error: 'Invalid email format' }); + continue; + } + + try { + const emailNorm = String(email).trim().toLowerCase(); + const emailLookup = crypto + .createHmac('sha256', EMAIL_INDEX_KEY) + .update(emailNorm) + .digest('hex'); + + // Check for existing user in BOTH user_profile (primary email) AND user_emails (secondary emails) + let userId = null; + let isNewUser = false; + + // First check user_profile for primary email + const [existingUsers] = await conn.execute( + 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1', + [emailLookup] + ); + + if (existingUsers.length > 0) { + userId = existingUsers[0].id; + } else { + // Check user_emails for secondary emails + const [secondaryEmails] = await conn.execute( + 'SELECT user_id FROM user_emails WHERE email_lookup = ? LIMIT 1', + [emailLookup] + ); + + if (secondaryEmails.length > 0) { + userId = secondaryEmails[0].user_id; + } + } + + // If user found (primary or secondary email), check if already enrolled in THIS org + if (userId) { + const [existingEnrollment] = await conn.execute( + 'SELECT id FROM organization_students WHERE organization_id = ? AND user_id = ?', + [orgId, userId] + ); + + if (existingEnrollment.length > 0) { + // Already enrolled - just update the record (grade level, status, etc.) + await conn.execute( + 'UPDATE organization_students SET enrollment_status = ?, grade_level = ?, updated_at = NOW() WHERE organization_id = ? AND user_id = ?', + [status, grade_level || null, orgId, userId] + ); + results.updated++; + // Don't send email for roster updates - skip to next student + continue; + } + // User exists but not enrolled in this org yet - will enroll below and send email + } else { + // Email not found anywhere - create shell user_profile for invitation tracking + const encEmail = encrypt(emailNorm); + const encFirstname = encrypt(firstname); + const encLastname = encrypt(lastname); + const [userResult] = await conn.execute( + 'INSERT INTO user_profile (email, email_lookup, firstname, lastname, state, area, is_premium, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, NOW())', + [encEmail, emailLookup, encFirstname, encLastname, orgState, orgArea] + ); + userId = userResult.insertId; + isNewUser = true; + } + + // Determine enrollment status: new users are pending_invitation, existing users are also pending_invitation until they accept + const enrollmentStatus = 'pending_invitation'; + + // Calculate onboarding trigger date + // K-12 Schools: delay for grades 11-12 only + // Other institutions: trigger immediately for all students + let triggerDate = null; + if (orgType === 'K-12 School') { + const shouldTriggerOnboarding = grade_level >= 11 && grade_level <= 12; + triggerDate = shouldTriggerOnboarding ? new Date(Date.now() + onboardingDelayDays * 24 * 60 * 60 * 1000) : null; + } else { + // Non-K12: trigger immediately + triggerDate = new Date(); + } + + await conn.execute( + 'INSERT INTO organization_students (organization_id, user_id, enrollment_status, enrollment_date, invitation_sent_at, grade_level, onboarding_triggered_at) VALUES (?, ?, ?, NOW(), ?, ?, ?)', + [orgId, userId, enrollmentStatus, isNewUser ? new Date() : null, grade_level || null, triggerDate] + ); + + // Do NOT insert privacy settings - let modal prompt user to configure on first login + + results.added++; + + // Send invitation email to ALL users (new and existing) + sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId, isNewUser).catch(emailErr => { + console.error(`[roster-upload] Email send failed for ${email}:`, emailErr.message); + }); + } catch (err) { + results.errors.push({ email, error: err.message }); + } + } + + // Get final count after processing (include active + pending, exclude deactivated) + const [finalCount] = await conn.execute( + 'SELECT COUNT(*) as count FROM organization_students WHERE organization_id = ? AND enrollment_status NOT IN (?, ?, ?)', + [orgId, 'withdrawn', 'transferred', 'inactive'] + ); + const totalRosterSize = finalCount[0].count; + let changePercentage = previousCount > 0 ? ((totalRosterSize - previousCount) / previousCount) * 100 : 100; + + // Ensure changePercentage is a valid number for decimal(5,2) + if (!isFinite(changePercentage)) changePercentage = 0; + changePercentage = Math.max(-999.99, Math.min(999.99, changePercentage)); + + // Insert roster update history record + await conn.execute( + `INSERT INTO roster_updates ( + organization_id, admin_user_id, update_type, update_window_start, update_window_end, submitted_at, + students_added, students_graduated, students_withdrawn, students_transferred, students_reactivated, + total_roster_size, change_percentage, discount_eligible, flagged_for_review + ) VALUES (?, ?, 'manual_upload', CURDATE(), CURDATE(), NOW(), ?, 0, 0, 0, 0, ?, ?, 0, 0)`, + [orgId, submittedBy, results.added, totalRosterSize, changePercentage] + ); + + await conn.commit(); + + return res.json({ + message: 'Roster upload completed', + changePercent: changePercentage.toFixed(2), + results + }); + } catch (err) { + await conn.rollback(); + console.error('[roster-upload] Error:', err.message); + if (err.stack) console.error(err.stack); + return res.status(500).json({ error: `Roster upload failed: ${err.message}` }); + } finally { + conn.release(); + } +}); + +app.get('/api/admin/roster/history', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [history] = await pool.execute(` + SELECT + ru.id, + ru.submitted_at AS uploaded_at, + ru.students_added, + ru.students_graduated + ru.students_withdrawn + ru.students_transferred AS students_existing, + ru.total_roster_size AS total_students_after, + ru.change_percentage + FROM roster_updates ru + WHERE ru.organization_id = ? + ORDER BY ru.submitted_at DESC + LIMIT 50 + `, [orgId]); + + // Return empty array if no history, not an error + return res.json(history || []); + } catch (err) { + console.error('[roster-history] Error:', err.message); + // Return empty array instead of error for better UX + return res.json([]); + } +}); + +app.get('/api/admin/roster/discount-status', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [latest] = await pool.execute( + 'SELECT * FROM roster_updates WHERE organization_id = ? ORDER BY submitted_at DESC LIMIT 1', + [orgId] + ); + + if (latest.length === 0) { + return res.json({ discountStatus: 'no_submissions', message: 'No roster submissions yet' }); + } + + return res.json({ + discountStatus: latest[0].discount_eligibility_status, + changePercent: latest[0].change_percent, + submittedAt: latest[0].submitted_at + }); + } catch (err) { + console.error('[discount-status] Error:', err.message); + return res.status(500).json({ error: 'Failed to load discount status' }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// ANALYTICS & REPORTS ENDPOINTS +// ═══════════════════════════════════════════════════════════════════════════ + +app.get('/api/admin/reports/engagement', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + const days = parseInt(req.query.days) || 30; + + const [dailyActive] = await pool.execute(` + SELECT DATE(up.last_login) as date, COUNT(DISTINCT up.id) as active_users + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + WHERE os.organization_id = ? + AND os.enrollment_status = 'active' + AND up.last_login >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY DATE(up.last_login) + ORDER BY date DESC + `, [orgId, days]); + + const [careerActivity] = await pool.execute(` + SELECT DATE(cv.viewed_at) as date, COUNT(*) as views + FROM career_views cv + JOIN organization_students os ON cv.user_id = os.user_id + WHERE os.organization_id = ? + AND cv.viewed_at >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY DATE(cv.viewed_at) + ORDER BY date DESC + `, [orgId, days]); + + return res.json({ + dailyActive, + careerActivity + }); + } catch (err) { + console.error('[engagement-report] Error:', err.message); + return res.status(500).json({ error: 'Failed to generate engagement report' }); + } +}); + +app.get('/api/admin/reports/careers', requireAdminAuth, async (req, res) => { + try { + const orgId = req.admin.organizationId; + const days = parseInt(req.query.days) || 90; + + const [careers] = await pool.execute(` + SELECT cv.career_name, cv.career_soc_code, + COUNT(DISTINCT cv.user_id) as unique_students, + COUNT(*) as total_views + FROM career_views cv + JOIN organization_students os ON cv.user_id = os.user_id + WHERE os.organization_id = ? + AND cv.viewed_at >= DATE_SUB(NOW(), INTERVAL ? DAY) + GROUP BY cv.career_soc_code, cv.career_name + ORDER BY unique_students DESC + LIMIT 50 + `, [orgId, days]); + + return res.json({ careers }); + } catch (err) { + console.error('[career-trends] Error:', err.message); + return res.status(500).json({ error: 'Failed to generate career trends report' }); + } +}); + +app.get('/api/admin/reports/export', requireAdminAuth, exportLimiter, async (req, res) => { + try { + const orgId = req.admin.organizationId; + + const [students] = await pool.execute(` + SELECT os.id, os.enrollment_status, os.enrollment_date, + up.firstname, up.lastname, up.email, up.last_login, up.created_at, + sps.share_career_exploration, sps.share_interest_inventory, + sps.share_college_research, sps.share_financial_data, sps.share_premium_features + FROM organization_students os + JOIN user_profile up ON os.user_id = up.id + LEFT JOIN student_privacy_settings sps ON os.user_id = sps.user_id AND os.organization_id = sps.organization_id + WHERE os.organization_id = ? + ORDER BY up.lastname, up.firstname + `, [orgId]); + + // Decrypt emails + for (const student of students) { + if (student.email) { + try { + student.email = decrypt(student.email); + } catch { + // Leave as-is + } + } + } + + const headers = [ + 'Student Number', 'First Name', 'Last Name', 'Email', + 'Enrollment Status', 'Enrollment Date', 'Last Login', 'Created At', + 'Share Career', 'Share Inventory', 'Share College', 'Share Financial', 'Share Premium' + ]; + + let csv = headers.join(',') + '\n'; + + for (let i = 0; i < students.length; i++) { + const student = students[i]; + const row = [ + i + 1, // Sequential student number instead of user_id + `"${student.firstname}"`, + `"${student.lastname}"`, + `"${student.email}"`, + student.enrollment_status, + student.enrollment_date, + student.last_login || '', + student.created_at, + student.share_career_exploration ? 'Y' : 'N', + student.share_interest_inventory ? 'Y' : 'N', + student.share_college_research ? 'Y' : 'N', + student.share_financial_data ? 'Y' : 'N', + student.share_premium_features ? 'Y' : 'N' + ]; + csv += row.join(',') + '\n'; + } + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="students_export_${Date.now()}.csv"`); + return res.send(csv); + } catch (err) { + console.error('[export-students] Error:', err.message); + return res.status(500).json({ error: 'Failed to export student data' }); + } +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// FINAL ERROR HANDLER +// ═══════════════════════════════════════════════════════════════════════════ + +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); + console.error(`[ref ${rid}]`, err?.message || err); + return res.status(500).json({ error: 'Server error', ref: rid }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// START SERVER +// ═══════════════════════════════════════════════════════════════════════════ + +app.listen(PORT, '0.0.0.0', () => { + console.log(`✓ Admin API (server4) running on port ${PORT}`); +}); diff --git a/backend/shared/db/withEncryption.js b/backend/shared/db/withEncryption.js index 972ed0f..b8d115d 100644 --- a/backend/shared/db/withEncryption.js +++ b/backend/shared/db/withEncryption.js @@ -15,6 +15,9 @@ const WRITE_RE = /^\s*(insert|update|replace)\s/i; /* ── map of columns that must be protected ─────────────────── */ const TABLE_MAP = { + user_auth : [ + 'username', 'date_of_birth' + ], user_profile : [ 'username', 'firstname', 'lastname', 'email', 'phone_e164', 'zipcode', 'stripe_customer_id', diff --git a/deploy_all.sh b/deploy_all.sh index 1b076c9..5b926d4 100755 --- a/deploy_all.sh +++ b/deploy_all.sh @@ -18,7 +18,7 @@ echo "🔧 Deploying environment: $ENV (GCP: $PROJECT)" SECRETS=( ENV_NAME PROJECT CORS_ALLOWED_ORIGINS TOKEN_MAX_AGE_MS COOKIE_SECURE COOKIE_SAMESITE ACCESS_COOKIE_NAME - SERVER1_PORT SERVER2_PORT SERVER3_PORT + SERVER1_PORT SERVER2_PORT SERVER3_PORT SERVER4_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 @@ -27,7 +27,7 @@ SECRETS=( 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 + GOOGLE_MAPS_API_KEY ADMIN_PORTAL_URL KMS_KEY_NAME DEK_PATH ) @@ -103,7 +103,7 @@ build_and_push () { docker push "${REG}/${svc}:${TAG}" } -SERVICES=(server1 server2 server3 nginx) +SERVICES=(server1 server2 server3 server4 nginx) # Build & push to DEV registry first (source of truth) for svc in "${SERVICES[@]}"; do diff --git a/docker-compose.yml b/docker-compose.yml index 1c9f3db..38c6022 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -173,12 +173,56 @@ services: retries: 5 start_period: 25s + # ───────────────────────────── server4 ───────────────────────────── + server4: + depends_on: [dek-init] + <<: *with-env + image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/server4:${IMG_TAG} + user: "1000:1000" + read_only: true + tmpfs: + - /tmp + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + expose: ["${SERVER4_PORT}"] + environment: + ENV_NAME: ${ENV_NAME} + PROJECT: ${PROJECT} + KMS_KEY_NAME: ${KMS_KEY_NAME} + DEK_PATH: ${DEK_PATH} + JWT_SECRET: ${JWT_SECRET} + APTIVA_API_BASE: ${APTIVA_API_BASE} + DB_HOST: ${DB_HOST} + DB_PORT: ${DB_PORT} + DB_USER: ${DB_USER} + DB_PASSWORD: ${DB_PASSWORD} + DB_NAME: ${DB_NAME} + DB_SSL_CERT: ${DB_SSL_CERT} + DB_SSL_KEY: ${DB_SSL_KEY} + DB_SSL_CA: ${DB_SSL_CA} + DB_POOL_SIZE: "6" + CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS} + ADMIN_PORTAL_URL: ${ADMIN_PORTAL_URL} + EMAIL_INDEX_SECRET: ${EMAIL_INDEX_SECRET} + SUPPORT_SENDGRID_API_KEY: ${SUPPORT_SENDGRID_API_KEY} + FROM_SECRETS_MANAGER: ${FROM_SECRETS_MANAGER} + volumes: + - dek-vol:/run/secrets/dev:ro + healthcheck: + test: ["CMD-SHELL", "curl -fsS http://localhost:${SERVER4_PORT}/healthz || exit 1"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 25s + # ───────────────────────────── nginx ─────────────────────────────── nginx: <<: *with-env image: us-central1-docker.pkg.dev/aptivaai-dev/aptiva-repo/nginx:${IMG_TAG} command: ["nginx", "-g", "daemon off;"] - depends_on: [server1, server2, server3] + depends_on: [server1, server2, server3, server4] networks: [default, aptiva-shared] environment: GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY} diff --git a/migrate_encrypted_columns.sql b/migrate_encrypted_columns.sql deleted file mode 100644 index 11f82cb..0000000 --- a/migrate_encrypted_columns.sql +++ /dev/null @@ -1,281 +0,0 @@ -/* ───────────────────────── user_profile ───────────────────────── */ -ALTER TABLE user_profile - MODIFY firstname VARCHAR(400), - MODIFY lastname VARCHAR(400), - MODIFY email VARCHAR(512), - MODIFY phone_e164 VARCHAR(128), - MODIFY zipcode VARCHAR(64), - MODIFY stripe_customer_id VARCHAR(128), - MODIFY interest_inventory_answers MEDIUMTEXT, - MODIFY riasec_scores VARCHAR(768), - MODIFY career_priorities MEDIUMTEXT, - MODIFY career_list MEDIUMTEXT; - -/* ───────────────────────── financial_profiles ─────────────────── */ -ALTER TABLE financial_profiles - MODIFY current_salary VARCHAR(128), - MODIFY additional_income VARCHAR(128), - MODIFY monthly_expenses VARCHAR(128), - MODIFY monthly_debt_payments VARCHAR(128), - MODIFY retirement_savings VARCHAR(128), - MODIFY emergency_fund VARCHAR(128), - MODIFY retirement_contribution VARCHAR(128), - MODIFY emergency_contribution VARCHAR(128), - MODIFY extra_cash_emergency_pct VARCHAR(64), - MODIFY extra_cash_retirement_pct VARCHAR(64); - -/* ───────────────────────── career_profiles ────────────────────── */ -ALTER TABLE career_profiles - MODIFY COLUMN career_name VARCHAR(255) NULL, - MODIFY COLUMN start_date VARCHAR(32) NULL, - MODIFY COLUMN retirement_start_date VARCHAR(32) NULL, - MODIFY COLUMN planned_monthly_expenses VARCHAR(128) NULL, - MODIFY COLUMN planned_monthly_debt_payments VARCHAR(128) NULL, - MODIFY COLUMN planned_monthly_retirement_contribution VARCHAR(128) NULL, - MODIFY COLUMN planned_monthly_emergency_contribution VARCHAR(128) NULL, - MODIFY COLUMN planned_surplus_emergency_pct VARCHAR(128) NULL, - MODIFY COLUMN planned_surplus_retirement_pct VARCHAR(128) NULL, - MODIFY COLUMN planned_additional_income VARCHAR(128) NULL, - MODIFY COLUMN career_goals MEDIUMTEXT NULL, - MODIFY COLUMN desired_retirement_income_monthly VARCHAR(128) NULL, - MODIFY COLUMN scenario_title VARCHAR(255) NULL; - -encryption_canary(id INTEGER PRIMARY KEY, value TEXT) - - - - ALTER TABLE college_profiles - MODIFY COLUMN selected_school VARCHAR(512) NULL, - MODIFY COLUMN selected_program VARCHAR(512) NULL, - MODIFY COLUMN annual_financial_aid VARCHAR(128) NULL, - MODIFY COLUMN existing_college_debt VARCHAR(128) NULL, - MODIFY COLUMN tuition VARCHAR(128) NULL, - MODIFY COLUMN tuition_paid VARCHAR(128) NULL, - MODIFY COLUMN loan_deferral_until_graduation VARCHAR(128) NULL, - MODIFY COLUMN loan_term VARCHAR(128) NULL, - MODIFY COLUMN interest_rate VARCHAR(128) NULL, - MODIFY COLUMN extra_payment VARCHAR(128) NULL, - MODIFY COLUMN expected_salary VARCHAR(128) NULL; - -ALTER TABLE user_profile - ADD COLUMN stripe_customer_id_hash CHAR(64) NULL, - ADD INDEX idx_customer_hash (stripe_customer_id_hash); - -/*─────────────────── - STEP 1 – drop old indexes - ───────────────────*/ - SHOW INDEX FROM college_profiles\G - -ALTER TABLE college_profiles - DROP FOREIGN KEY fk_college_profiles_user, - DROP FOREIGN KEY fk_college_profiles_career; - -ALTER TABLE college_profiles - DROP INDEX user_id; - -/*─────────────────── - STEP 2 – widen columns - (512‑byte text columns ≈ 684 B once encrypted/Base64‑encoded) - ───────────────────*/ -ALTER TABLE college_profiles - MODIFY selected_school VARCHAR(512), - MODIFY selected_program VARCHAR(512), - MODIFY annual_financial_aid VARCHAR(128), - MODIFY existing_college_debt VARCHAR(128), - MODIFY tuition VARCHAR(128), - MODIFY tuition_paid VARCHAR(128), - MODIFY loan_deferral_until_graduation VARCHAR(64), - MODIFY loan_term VARCHAR(64), - MODIFY interest_rate VARCHAR(64), - MODIFY extra_payment VARCHAR(128), - MODIFY expected_salary VARCHAR(128); - -ALTER TABLE college_profiles - ADD UNIQUE KEY ux_user_school_prog ( - user_id, - career_profile_id, - selected_school(192), - selected_program(192), - program_type - ); - -ALTER TABLE college_profiles - ADD CONSTRAINT fk_college_profiles_user - FOREIGN KEY (user_id) REFERENCES user_profile(id) - ON DELETE CASCADE ON UPDATE CASCADE, - ADD CONSTRAINT fk_college_profiles_career - FOREIGN KEY (career_profile_id) REFERENCES career_profiles(id) - ON DELETE CASCADE ON UPDATE CASCADE; - -ALTER TABLE college_profiles - ADD INDEX idx_college_user (user_id), - ADD INDEX idx_college_career (career_profile_id); - -/*─────────────────── - STEP 3 – recreate indexes with safe prefixes (optional) - If you *don’t* need to query by these columns any more, - just comment‑out or delete this block. - ───────────────────*/ -CREATE INDEX idx_school ON college_profiles (selected_school(191)); -CREATE INDEX idx_program ON college_profiles (selected_program(191)); -CREATE INDEX idx_school_prog ON college_profiles (selected_school(191), - selected_program(191)); - -/* ───────────────────────── misc small tables ──────────────────── */ -ALTER TABLE milestones - MODIFY COLUMN title VARCHAR(255) NULL, - MODIFY COLUMN description MEDIUMTEXT NULL, - MODIFY COLUMN date VARCHAR(32) NULL, - MODIFY COLUMN progress VARCHAR(16) NULL; - -ALTER TABLE tasks - MODIFY COLUMN title VARCHAR(255) NULL, - MODIFY COLUMN description MEDIUMTEXT NULL, - MODIFY COLUMN due_date VARCHAR(32) NULL; - -ALTER TABLE reminders - MODIFY COLUMN phone_e164 VARCHAR(128) NULL, - MODIFY COLUMN message_body MEDIUMTEXT NULL; - -ALTER TABLE milestone_impacts - MODIFY COLUMN impact_type VARCHAR(64) NULL, - MODIFY COLUMN direction VARCHAR(32) NULL, - MODIFY COLUMN amount VARCHAR(128) NULL; - -ALTER TABLE user_profile - ADD UNIQUE KEY ux_email_lookup (email_lookup); - -ALTER TABLE ai_risk_analysis - MODIFY reasoning MEDIUMTEXT, - MODIFY raw_prompt MEDIUMTEXT; - -ALTER TABLE ai_generated_ksa - MODIFY knowledge_json MEDIUMTEXT, - MODIFY abilities_json MEDIUMTEXT, - MODIFY skills_json MEDIUMTEXT; - -ALTER TABLE ai_suggested_milestones - MODIFY suggestion_text MEDIUMTEXT; - -ALTER TABLE context_cache - MODIFY ctx_text MEDIUMTEXT; - - -ALTER TABLE user_profile - ADD COLUMN email_lookup CHAR(64) NOT NULL DEFAULT '' AFTER email; - -CREATE INDEX idx_user_profile_email_lookup - ON user_profile (email_lookup); - -CREATE INDEX idx_password_resets_token_hash ON password_resets (token_hash); - -ALTER TABLE user_auth - ADD COLUMN password_changed_at BIGINT UNSIGNED NULL AFTER hashed_password; - --- Optional but useful: -CREATE INDEX ix_user_auth_userid_changedat ON user_auth (user_id, password_changed_at); - -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; - --- Orphan message thread_ids (no matching thread row) -SELECT DISTINCT m.thread_id -FROM ai_chat_messages m -LEFT JOIN ai_chat_threads t ON t.id = m.thread_id -WHERE t.id IS NULL; - -INSERT INTO ai_chat_threads (id, user_id, bot_type, title) -SELECT m.thread_id, 58, 'coach', 'CareerCoach chat' -FROM ai_chat_messages m -LEFT JOIN ai_chat_threads t ON t.id = m.thread_id -WHERE t.id IS NULL; - -ALTER TABLE ai_chat_messages -ADD CONSTRAINT fk_messages_thread -FOREIGN KEY (thread_id) REFERENCES ai_chat_threads(id) -ON DELETE CASCADE; - - -mysqldump \ - --host=34.67.180.54 \ - --port=3306 \ - --user=sqluser \ - -p \ - --no-data \ - --routines \ - --triggers \ - --events \ - user_profile_db > full_schema.sql - - --- /home/jcoakley/sql/2025-09-11_add_verification_flags.sql -ALTER TABLE user_profile - ADD COLUMN email_verified_at DATETIME NULL AFTER email_lookup, - ADD COLUMN phone_verified_at DATETIME NULL AFTER phone_e164; - - ALTER TABLE user_profile - ADD COLUMN sms_reminders_opt_in TINYINT(1) NOT NULL DEFAULT 0, - ADD COLUMN sms_reminders_opt_in_at DATETIME NULL; - - --- 005_alter_career_profiles__attach_resume.sql - -ALTER TABLE career_profiles - ADD COLUMN resume_storage_key VARCHAR(255) NULL AFTER career_goals, - ADD COLUMN resume_filename VARCHAR(160) NULL AFTER resume_storage_key, - ADD COLUMN resume_filesize INT UNSIGNED NULL AFTER resume_filename, - ADD COLUMN resume_uploaded_at DATETIME NULL AFTER resume_filesize; - - -CREATE TABLE IF NOT EXISTS demo_requests ( - id INT AUTO_INCREMENT PRIMARY KEY, - name VARCHAR(512) NOT NULL, - email VARCHAR(512) NOT NULL, - organization_name VARCHAR(512) NOT NULL, - phone VARCHAR(512), - message TEXT, - source VARCHAR(512) DEFAULT 'conference', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - INDEX idx_created (created_at), - INDEX idx_source (source(255)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/nginx.conf b/nginx.conf index 87aa974..4e3c77b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -16,9 +16,10 @@ http { # ───────────── upstreams to Docker services ───────────── upstream backend5000 { server server1:5000; } # auth & free - upstream backend5001 { server server2:5001; + upstream backend5001 { server server2:5001; keepalive 1024;} # onet, distance, etc. upstream backend5002 { server server3:5002; } # premium + upstream backend5003 { server server4:5003; } # admin portal upstream gitea_backend { server gitea:3000; } # gitea service (shared network) upstream woodpecker_backend { server woodpecker-server:8000; } @@ -195,10 +196,23 @@ http { limit_req zone=reqperip burst=10 nodelay; proxy_pass http://backend5000; } + location = /api/privacy-settings { limit_conn perip 5; + limit_req zone=reqperip burst=10 nodelay; + proxy_pass http://backend5000; } + + location = /api/track-career-view { limit_conn perip 10; + limit_req zone=reqperip burst=20 nodelay; + proxy_pass http://backend5000; } + location = /api/demo-request { limit_conn perip 5; limit_req zone=reqperip burst=10 nodelay; proxy_pass http://backend5001; } + # Admin portal API routes (server4) + location ^~ /api/admin/ { limit_conn perip 10; + limit_req zone=reqperip burst=20 nodelay; + proxy_pass http://backend5003; } + # General API (anything not matched above) – rate-limited location ^~ /api/ { proxy_pass http://backend5000; } @@ -212,6 +226,69 @@ http { location = /50x.html { root /usr/share/nginx/html; } } + ######################################################################## + # 2.5 Admin Portal (HTTPS) dev1.admin.aptivaai.com + ######################################################################## + server { + listen 443 ssl; + http2 on; + server_name dev1.admin.aptivaai.com; + + ssl_certificate /etc/letsencrypt/live/dev1.admin.aptivaai.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/dev1.admin.aptivaai.com/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + # Security headers + server_tokens off; + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header X-Frame-Options DENY always; + + client_max_body_size 10m; + + # Host validation + if ($host !~* ^(dev1\.admin\.aptivaai\.com)$) { return 444; } + + # Serve same React app (admin routes protected by React Router + auth) + root /usr/share/nginx/html; + index index.html; + + # React SPA - serve index.html for all routes + location / { + try_files $uri $uri/index.html /index.html; + } + + # Static assets + location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg)$ { + expires 6M; + access_log off; + } + + # Admin API routes to server4 + location ^~ /api/admin/ { + limit_conn perip 10; + limit_req zone=reqperip burst=20 nodelay; + proxy_pass http://backend5003; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Other API routes still work (for shared endpoints if needed) + location ^~ /api/ { + proxy_pass http://backend5000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + error_page 502 503 504 /50x.html; + location = /50x.html { root /usr/share/nginx/html; } + } + ######################################################################## # 3. Gitea virtual host (HTTPS) gitea.dev1.aptivaai.com ######################################################################## diff --git a/src/App.js b/src/App.js index 8db0d31..0d97470 100644 --- a/src/App.js +++ b/src/App.js @@ -15,6 +15,8 @@ import SessionExpiredHandler from './components/SessionExpiredHandler.js'; import SignInLanding from './components/SignInLanding.js'; import SignIn from './components/SignIn.js'; import SignUp from './components/SignUp.js'; +import InviteResponse from './components/InviteResponse.js'; +import LinkSecondaryEmail from './components/LinkSecondaryEmail.js'; import PlanningLanding from './components/PlanningLanding.js'; import CareerExplorer from './components/CareerExplorer.js'; import PreparingLanding from './components/PreparingLanding.js'; @@ -49,11 +51,21 @@ import VerificationGate from './components/VerificationGate.js'; import Verify from './components/Verify.js'; import { initNetObserver } from './utils/net.js'; import PrivacyPolicy from './components/PrivacyPolicy.js'; +import PrivacySettings from './components/PrivacySettings.js'; +import PrivacySettingsModal from './components/PrivacySettingsModal.js'; import TermsOfService from './components/TermsOfService.js'; import HomePage from './components/HomePage.js'; import DemoRequest from './components/DemoRequest.js'; - +// Admin Portal Components +import { AdminProvider, useAdmin } from './contexts/AdminContext.js'; +import AdminLogin from './components/Admin/AdminLogin.js'; +import Dashboard from './components/Admin/Dashboard.js'; +import StudentList from './components/Admin/StudentList.js'; +import AddStudent from './components/Admin/AddStudent.js'; +import StudentDetail from './components/Admin/StudentDetail.js'; +import Settings from './components/Admin/Settings.js'; +import RosterUpload from './components/Admin/RosterUpload.js'; export const ProfileCtx = React.createContext(); @@ -185,6 +197,9 @@ const canShowRetireBot = // Logout warning modal const [showLogoutWarning, setShowLogoutWarning] = useState(false); + // Privacy settings modal (show once after organization signup) + const [showPrivacyModal, setShowPrivacyModal] = useState(false); + const [hasOrgEnrollment, setHasOrgEnrollment] = useState(false); // Check if user can access premium const canAccessPremium = user?.is_premium || user?.is_pro_premium; @@ -222,19 +237,19 @@ const showPremiumCTA = !premiumPaths.some(p => // ============================== useEffect(() => { let cancelled = false; -if (loggingOut) return; + if (loggingOut) return; // Skip auth probe on all public auth routes - if ( - location.pathname.startsWith('/reset-password') || - location.pathname === '/signin' || - location.pathname === '/signup' || - location.pathname === '/forgot-password' || - location.pathname === '/privacy' || - location.pathname === '/terms' || - location.pathname === '/home' || - location.pathname === '/demo' - ) { + if ( + location.pathname.startsWith('/reset-password') || + location.pathname === '/signin' || + location.pathname === '/signup' || + location.pathname === '/forgot-password' || + location.pathname === '/privacy' || + location.pathname === '/terms' || + location.pathname === '/home' || + location.pathname === '/demo' + ) { try { localStorage.removeItem('id'); } catch {} setIsAuthenticated(false); setUser(null); @@ -255,7 +270,28 @@ if (loggingOut) return; is_premium : !!data?.is_premium, is_pro_premium: !!data?.is_pro_premium, })); + + // Clear user-specific localStorage on login to prevent cross-user contamination + try { + localStorage.removeItem('selectedCareer'); + localStorage.removeItem('lastSelectedCareerProfileId'); + localStorage.removeItem('aiRecommendations'); + localStorage.removeItem('premiumOnboardingPointer'); + } catch {} + setIsAuthenticated(true); + + // Check if onboarding should be triggered for roster students + try { + const { data: onboardingStatus } = await api.get('/api/onboarding-status'); + if (onboardingStatus?.shouldTrigger && !IN_OB(location.pathname)) { + // Redirect to premium onboarding if trigger date has passed + navigate('/premium-onboarding', { replace: true }); + } + } catch (obErr) { + // Silently fail - onboarding check is non-critical + console.warn('Failed to check onboarding status:', obErr); + } } catch (err) { if (cancelled) return; clearToken(); @@ -272,7 +308,9 @@ if (loggingOut) return; p === '/privacy' || p === '/terms' || p === '/home' || - p === '/demo'; + p === '/demo' || + p === '/invite-response' || + p === '/link-secondary-email'; if (!onPublic) navigate('/signin?session=expired', { replace: true }); } finally { if (!cancelled) setIsLoading(false); @@ -285,6 +323,40 @@ if (loggingOut) return; // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.pathname, navigate, loggingOut]); + // ========================== + // Check for org enrollment and show privacy modal + // ========================== + useEffect(() => { + if (!isAuthenticated || !user) return; + + // Check if user signed up via invitation (has org enrollment) and hasn't configured privacy yet + const checkPrivacySettings = async () => { + try { + const { data } = await api.get('/api/privacy-settings'); + if (data.organizations && data.organizations.length > 0) { + setHasOrgEnrollment(true); + + // Check if privacy settings haven't been configured yet + // (has_configured flag indicates if they've saved settings at least once) + const allConfigured = data.organizations.every(org => org.has_configured); + + // Only show modal if they haven't configured settings yet + if (!allConfigured) { + // Small delay to let the UI settle after login/signup + setTimeout(() => setShowPrivacyModal(true), 500); + } + } else { + setHasOrgEnrollment(false); + } + } catch (err) { + console.error('[App] Error checking privacy settings:', err); + setHasOrgEnrollment(false); + } + }; + + checkPrivacySettings(); + }, [isAuthenticated, user]); + // ========================== // 2) Logout Handler + Modal // ========================== @@ -561,6 +633,14 @@ const cancelLogout = () => { > Account + {hasOrgEnrollment && ( + + Privacy Settings + + )} { {mobileSection === 'profile' && (
- + Account + {hasOrgEnrollment && ( + Privacy Settings + )} Financial Profile {canAccessPremium ? ( Career Profiles @@ -846,6 +929,16 @@ const cancelLogout = () => { element={isAuthenticated ? : } /> + } + /> + + } + /> + : } @@ -863,6 +956,7 @@ const cancelLogout = () => { } /> } /> } /> + } /> } /> } /> } /> @@ -935,10 +1029,66 @@ const cancelLogout = () => { {/* Session Handler (Optional) */} + + {/* Privacy Settings Modal (for organization students) */} + setShowPrivacyModal(false)} + />
); } -export default App; +// Admin Portal Routes (separate from main app) +function AdminApp() { + const { admin, loading, isAdminPortal } = useAdmin(); + + // If not on admin subdomain, don't render admin app + if (!isAdminPortal) return null; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( + + : } /> + : } /> + : } /> + : } /> + : } /> + : } /> + : } /> + } /> + } /> + + ); +} + +// Main App Wrapper +function AppWithProvider() { + return ( + + + + ); +} + +// Switch between admin and student apps based on subdomain +function AdminAppSwitcher() { + const { isAdminPortal } = useAdmin(); + + if (isAdminPortal) { + return ; + } + + return ; +} + +export default AppWithProvider; diff --git a/src/components/Admin/AddStudent.js b/src/components/Admin/AddStudent.js new file mode 100644 index 0000000..4920897 --- /dev/null +++ b/src/components/Admin/AddStudent.js @@ -0,0 +1,155 @@ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import axios from 'axios'; +import AdminLayout from './AdminLayout.js'; +import { Button } from '../ui/button.js'; +import { Input } from '../ui/input.js'; +import { ArrowLeft } from 'lucide-react'; + +export default function AddStudent() { + const navigate = useNavigate(); + const [formData, setFormData] = useState({ + email: '', + firstname: '', + lastname: '' + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleChange = (e) => { + setFormData({ + ...formData, + [e.target.name]: e.target.value + }); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(null); + setLoading(true); + + try { + await axios.post('/api/admin/students', formData, { + withCredentials: true + }); + + // Success - redirect to student list + navigate('/admin/students', { + state: { message: 'Student added successfully!' } + }); + } catch (err) { + setError(err.response?.data?.error || 'Failed to add student'); + setLoading(false); + } + }; + + return ( + +
+
+ + + Back to Students + +

Add Student

+

+ Add a new student to your organization +

+
+ +
+
+ {error && ( +
+
{error}
+
+ )} + +
+ + +

+ Student will receive an invitation email at this address +

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+

+ What happens next? +

+
    +
  • • Student will be added to your organization with "active" status
  • +
  • • An invitation email will be sent to the student
  • +
  • • Student can register and link their account using this email
  • +
  • • Default privacy settings allow you to see career exploration data
  • +
+
+
+
+ ); +} diff --git a/src/components/Admin/AdminLayout.js b/src/components/Admin/AdminLayout.js new file mode 100644 index 0000000..546caab --- /dev/null +++ b/src/components/Admin/AdminLayout.js @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { useAdmin } from '../../contexts/AdminContext.js'; +import { Button } from '../ui/button.js'; +import { + LayoutDashboard, + Users, + Settings, + Upload, + LogOut, + Menu, + X +} from 'lucide-react'; + +export default function AdminLayout({ children }) { + const { admin, logout, isSuperAdmin } = useAdmin(); + const location = useLocation(); + const navigate = useNavigate(); + const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + + const handleLogout = async () => { + await logout(); + navigate('/admin/login'); + }; + + const navItems = [ + { path: '/admin/dashboard', label: 'Dashboard', icon: LayoutDashboard }, + { path: '/admin/students', label: 'Students', icon: Users }, + { path: '/admin/roster', label: 'Roster Upload', icon: Upload }, + ...(isSuperAdmin ? [{ path: '/admin/settings', label: 'Settings', icon: Settings }] : []) + ]; + + return ( +
+ {/* Top Navigation Bar */} + + + {/* Main Content */} +
+ {children} +
+
+ ); +} diff --git a/src/components/Admin/AdminLogin.js b/src/components/Admin/AdminLogin.js new file mode 100644 index 0000000..2043269 --- /dev/null +++ b/src/components/Admin/AdminLogin.js @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useAdmin } from '../../contexts/AdminContext.js'; +import { Button } from '../ui/button.js'; +import { Input } from '../ui/input.js'; + +export default function AdminLogin() { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const { login } = useAdmin(); + const navigate = useNavigate(); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + + const result = await login(username, password); + + if (result.success) { + navigate('/admin/dashboard'); + } else { + setError(result.error); + setLoading(false); + } + }; + + return ( +
+
+
+

+ Organization Admin Portal +

+

+ Sign in to manage your organization +

+
+ +
+ {error && ( +
+
{error}
+
+ )} + +
+
+ + setUsername(e.target.value)} + placeholder="Enter your username" + disabled={loading} + /> +
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + disabled={loading} + /> +
+
+ +
+ +
+
+ +
+ © {new Date().getFullYear()} AptivaAI™ LLC +
+
+
+ ); +} diff --git a/src/components/Admin/BouncedInvitations.js b/src/components/Admin/BouncedInvitations.js new file mode 100644 index 0000000..16f4d27 --- /dev/null +++ b/src/components/Admin/BouncedInvitations.js @@ -0,0 +1,244 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import AdminLayout from './AdminLayout.js'; +import { Button } from '../ui/button.js'; +import { Input } from '../ui/input.js'; +import { ArrowLeft, AlertCircle, Edit2, X } from 'lucide-react'; + +export default function BouncedInvitations() { + const [students, setStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editingStudent, setEditingStudent] = useState(null); + const [newEmail, setNewEmail] = useState(''); + const [updating, setUpdating] = useState(false); + + const fetchBouncedInvitations = async () => { + try { + setLoading(true); + const { data } = await axios.get('/api/admin/students?status=invitation_bounced', { + withCredentials: true + }); + setStudents(data.students || []); + setError(null); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load bounced invitations'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchBouncedInvitations(); + }, []); + + const handleEditEmail = (student) => { + setEditingStudent(student); + setNewEmail(student.email); + }; + + const handleCancelEdit = () => { + setEditingStudent(null); + setNewEmail(''); + }; + + const handleUpdateEmail = async () => { + if (!newEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(newEmail)) { + alert('Please enter a valid email address'); + return; + } + + setUpdating(true); + try { + await axios.post(`/api/admin/students/${editingStudent.user_id}/update-email`, { + email: newEmail + }, { + withCredentials: true + }); + + alert('Email updated and invitation resent successfully'); + setEditingStudent(null); + setNewEmail(''); + fetchBouncedInvitations(); // Refresh the list + } catch (err) { + alert(err.response?.data?.error || 'Failed to update email'); + } finally { + setUpdating(false); + } + }; + + const formatDate = (dateString) => { + if (!dateString) return 'Unknown'; + const date = new Date(dateString); + const now = new Date(); + const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return '1 day ago'; + if (diffDays < 7) return `${diffDays} days ago`; + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + }; + + return ( + +
+
+ + + Back to Students + + +
+
+

+ + Bounced Invitations + {students.length > 0 && ( + + {students.length} + + )} +

+

+ These email addresses are invalid or unreachable. Update the email and resend. +

+
+
+
+ + {error && ( +
+
{error}
+
+ )} + + {loading ? ( +
+
+

Loading bounced invitations...

+
+ ) : students.length === 0 ? ( +
+ +

No bounced invitations

+

+ All invitation emails were delivered successfully +

+
+ ) : ( +
+ + + + + + + + + + + + {students.map((student) => ( + + + + + + + + ))} + +
+ Name + + Email + + Bounced + + Reason + + Actions +
+
+ {student.firstname} {student.lastname} +
+
+
{student.email}
+
+ {formatDate(student.status_changed_date || student.updated_at)} + + + {student.bounce_reason || 'Email address invalid'} + + + +
+
+ )} + + {/* Edit Email Modal */} + {editingStudent && ( +
+
+
+

Update Email Address

+ +
+ +
+
+ +

+ {editingStudent.firstname} {editingStudent.lastname} +

+
+ +
+ +

{editingStudent.email}

+
+ +
+ + setNewEmail(e.target.value)} + placeholder="student@example.com" + className="w-full" + /> +
+
+ +
+ + +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/Admin/Dashboard.js b/src/components/Admin/Dashboard.js new file mode 100644 index 0000000..cfbc739 --- /dev/null +++ b/src/components/Admin/Dashboard.js @@ -0,0 +1,538 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import AdminLayout from './AdminLayout.js'; +import { Users, UserCheck, TrendingUp, Shield, Clock, DollarSign, ArrowUpRight, AlertCircle, Upload as UploadIcon } from 'lucide-react'; + +export default function Dashboard() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Career Interests filters + const [careerInterests, setCareerInterests] = useState([]); + const [signalStrength, setSignalStrength] = useState('viewed'); + const [timePeriod, setTimePeriod] = useState('90'); + const [loadingCareers, setLoadingCareers] = useState(false); + + useEffect(() => { + const fetchStats = async () => { + try { + const { data } = await axios.get('/api/admin/dashboard/stats', { + withCredentials: true + }); + setStats(data); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load dashboard stats'); + } finally { + setLoading(false); + } + }; + + fetchStats(); + }, []); + + useEffect(() => { + const fetchCareerInterests = async () => { + setLoadingCareers(true); + try { + const { data } = await axios.get('/api/admin/dashboard/career-interests', { + params: { signalStrength, timePeriod }, + withCredentials: true + }); + setCareerInterests(data.careers || []); + } catch (err) { + console.error('Failed to load career interests:', err); + setCareerInterests([]); + } finally { + setLoadingCareers(false); + } + }; + + if (!loading) { + fetchCareerInterests(); + } + }, [signalStrength, timePeriod, loading]); + + if (loading) { + return ( + +
+
+

Loading dashboard...

+
+
+ ); + } + + if (error) { + return ( + +
+
{error}
+
+
+ ); + } + + return ( + +
+
+

Dashboard

+

+ Overview of your organization's student engagement +

+
+ + {/* Stats Cards */} +
+ {/* Total Students */} +
+
+
+
+ +
+
+
+
+ Total Students +
+
+ {stats?.totalStudents || 0} +
+
+
+
+
+
+ + {/* Active Students (30 days) */} +
+
+
+
+ +
+
+
+
+ Active Students (30 days) +
+
+ {stats?.activeStudents || 0} +
+
+
+
+
+
+ {stats?.totalStudents > 0 + ? `${Math.round((stats.activeStudents / stats.totalStudents) * 100)}% of total` + : '0% of total'} +
+
+
+
+ + {/* Engagement Rate */} +
+
+
+
+ +
+
+
+
+ Engagement Rate +
+
+ {stats?.totalStudents > 0 + ? `${Math.round((stats.activeStudents / stats.totalStudents) * 100)}%` + : '0%'} +
+
+
+
+
+
+ + {/* Admin Count */} +
+
+
+
+ +
+
+
+
+ Admin Users +
+
+ {stats?.adminCount || 0} +
+
+
+
+
+
+
+ + {/* Student Career Interests */} +
+
+
+

+ Student Career Interests +

+
+ + {/* Filters */} +
+
+ + +
+ +
+ + +
+
+ + {/* Career List */} + {loadingCareers ? ( +
+
+
+ ) : careerInterests.length > 0 ? ( +
+ {careerInterests.slice(0, 10).map((career, index) => ( +
+
+
+ {index + 1} +
+
+

+ {career.career_name} +

+
+
+
+ + {career.student_count} {career.student_count === 1 ? 'student' : 'students'} + +
+
+ ))} +
+ ) : ( +

+ No career exploration data available for the selected filters. +

+ )} +
+
+ + {/* Highest Paying Careers - moved up from below */} + {stats?.topPayingCareers && stats.topPayingCareers.length > 0 && ( +
+
+

+ Highest Paying Careers +

+

Regional Salary

+
+ {stats.topPayingCareers.map((career, index) => ( +
+
+
+ {index + 1} +
+
+

+ {career.career_name} +

+
+
+
+
+ + + {career.median_salary ? `${(career.median_salary / 1000).toFixed(0)}k` : 'N/A'} + +
+
+
+ ))} +
+
+
+ )} + + {/* Most Jobs Projected */} + {stats?.mostJobsCareers && stats.mostJobsCareers.length > 0 && ( +
+
+

+ Most Jobs Projected (Next 10 Years) +

+

{stats.orgState}

+
+ {stats.mostJobsCareers.map((career, index) => ( +
+
+
+ {index + 1} +
+
+

+ {career.career_name} +

+
+
+
+ + {career.projected_jobs ? career.projected_jobs.toLocaleString() : 'N/A'} + + jobs +
+
+ ))} +
+
+
+ )} + + {/* Career Outcome Metrics */} + {stats?.careerOutcomes && ( +
+
+

+ Career Planning Outcomes +

+
+ {/* Career Profile Creation */} +
+
+

Students with Career Profiles

+

Created at least one career profile

+
+
+

{stats.careerOutcomes.studentsWithCareerProfiles}

+

+ {stats.totalStudents > 0 + ? `${Math.round((stats.careerOutcomes.studentsWithCareerProfiles / stats.totalStudents) * 100)}%` + : '0%'} of total +

+
+
+ + {/* Career Exploration Match */} +
+
+

Career Profiles Matching Exploration

+

Chose a career they explored in Career Explorer

+
+
+

{stats.careerOutcomes.careerProfilesMatchingExploration}

+

+ {stats.careerOutcomes.studentsWithCareerProfiles > 0 + ? `${Math.round((stats.careerOutcomes.careerProfilesMatchingExploration / stats.careerOutcomes.studentsWithCareerProfiles) * 100)}%` + : '0%'} match rate +

+
+
+ + {/* Career List Match */} +
+
+

Career Profiles from Career Comparison

+

Chose a career from their comparison list

+
+
+

{stats.careerOutcomes.careerProfilesMatchingCareerComparison}

+

+ {stats.careerOutcomes.studentsWithCareerProfiles > 0 + ? `${Math.round((stats.careerOutcomes.careerProfilesMatchingCareerComparison / stats.careerOutcomes.studentsWithCareerProfiles) * 100)}%` + : '0%'} match rate +

+
+
+ + {/* College Profile Creation */} +
+
+

Students with College Profiles

+

Created at least one college/program profile

+
+
+

{stats.careerOutcomes.studentsWithCollegeProfiles}

+

+ {stats.totalStudents > 0 + ? `${Math.round((stats.careerOutcomes.studentsWithCollegeProfiles / stats.totalStudents) * 100)}%` + : '0%'} of total +

+
+
+
+
+
+ )} + + {/* Recent Roster Uploads */} + {stats?.recentUploads && stats.recentUploads.length > 0 && ( +
+
+

+ Recent Roster Uploads +

+
+ {stats.recentUploads.map((upload, index) => ( +
+
+ +
+

+ {new Date(upload.submitted_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + })} +

+

+ Added {upload.students_added} student{upload.students_added !== 1 ? 's' : ''} +

+
+
+
+

+ {upload.total_roster_size} total +

+

roster size

+
+
+ ))} +
+
+ + View upload history → + +
+
+
+ )} + + {/* Roster Update Reminders */} + {stats?.rosterReminders && stats.rosterReminders.length > 0 && ( +
+
+

+ Roster Update Reminders +

+
+ {stats.rosterReminders.map((reminder, index) => ( +
+
+
+ +
+

+ {reminder.term} Term Roster Update {reminder.status === 'overdue' ? 'Overdue' : 'Due Soon'} +

+

+ {reminder.status === 'overdue' + ? `${reminder.daysUntil} days overdue` + : `Due in ${reminder.daysUntil} days`} - Deadline: {new Date(reminder.deadline).toLocaleDateString()} +

+
+
+ + + Update Roster + +
+
+ ))} +
+
+
+ )} + + {/* Fastest Growing Careers */} + {stats?.fastestGrowingCareers && stats.fastestGrowingCareers.length > 0 && ( +
+
+

+ Fastest Growing Careers +

+

{stats.orgState}

+
+ {stats.fastestGrowingCareers.map((career, index) => ( +
+
+
+ {index + 1} +
+
+

+ {career.career_name} +

+
+
+
+
+ + + {career.growth_rate ? `${career.growth_rate.toFixed(1)}%` : 'N/A'} + +
+
+
+ ))} +
+
+
+ )} +
+
+ ); +} diff --git a/src/components/Admin/PendingInvitations.js b/src/components/Admin/PendingInvitations.js new file mode 100644 index 0000000..751d04e --- /dev/null +++ b/src/components/Admin/PendingInvitations.js @@ -0,0 +1,188 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import AdminLayout from './AdminLayout.js'; +import { Button } from '../ui/button.js'; +import { ArrowLeft, Mail, RefreshCw } from 'lucide-react'; + +export default function PendingInvitations() { + const [students, setStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [resending, setResending] = useState(null); + + const fetchPendingInvitations = async () => { + try { + setLoading(true); + const { data } = await axios.get('/api/admin/students?status=pending_invitation', { + withCredentials: true + }); + setStudents(data.students || []); + setError(null); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load pending invitations'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchPendingInvitations(); + }, []); + + const handleResend = async (userId, email) => { + setResending(userId); + try { + await axios.post(`/api/admin/students/${userId}/resend-invitation`, {}, { + withCredentials: true + }); + alert(`Invitation resent to ${email}`); + } catch (err) { + alert(err.response?.data?.error || 'Failed to resend invitation'); + } finally { + setResending(null); + } + }; + + const handleResendAll = async () => { + if (!window.confirm(`Resend invitations to all ${students.length} pending students?`)) return; + + try { + await axios.post('/api/admin/students/resend-all-invitations', {}, { + withCredentials: true + }); + alert(`Invitations resent to ${students.length} students`); + } catch (err) { + alert(err.response?.data?.error || 'Failed to resend invitations'); + } + }; + + const formatDate = (dateString) => { + if (!dateString) return 'Unknown'; + const date = new Date(dateString); + const now = new Date(); + const diffDays = Math.floor((now - date) / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) return 'Today'; + if (diffDays === 1) return '1 day ago'; + if (diffDays < 7) return `${diffDays} days ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`; + return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }); + }; + + return ( + +
+
+ + + Back to Students + + +
+
+

+ + Pending Invitations + {students.length > 0 && ( + + {students.length} + + )} +

+

+ These students have been invited but haven't created their accounts yet +

+
+ + {students.length > 0 && ( + + )} +
+
+ + {error && ( +
+
{error}
+
+ )} + + {loading ? ( +
+
+

Loading pending invitations...

+
+ ) : students.length === 0 ? ( +
+ +

No pending invitations

+

+ All invited students have created their accounts +

+
+ ) : ( +
+ + + + + + + + + + + {students.map((student) => ( + + + + + + + ))} + +
+ Name + + Email + + Invited + + Actions +
+
+ {student.firstname} {student.lastname} +
+
+
{student.email}
+
+ {formatDate(student.invitation_sent_at || student.created_at)} + + +
+
+ )} +
+
+ ); +} diff --git a/src/components/Admin/RosterUpload.js b/src/components/Admin/RosterUpload.js new file mode 100644 index 0000000..61be8f9 --- /dev/null +++ b/src/components/Admin/RosterUpload.js @@ -0,0 +1,438 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Upload, FileText, AlertCircle, CheckCircle, Download, History } from 'lucide-react'; +import AdminLayout from './AdminLayout.js'; + +export default function RosterUpload() { + const [file, setFile] = useState(null); + const [preview, setPreview] = useState([]); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [history, setHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(true); + const [activeTab, setActiveTab] = useState('upload'); // 'upload' | 'history' + const [orgType, setOrgType] = useState(null); + const [loadingOrgType, setLoadingOrgType] = useState(true); + + useEffect(() => { + fetchHistory(); + fetchOrgType(); + }, []); + + const fetchHistory = async () => { + try { + const { data } = await axios.get('/api/admin/roster/history', { + withCredentials: true + }); + setHistory(data); + } catch (err) { + console.error('Failed to load roster history:', err); + } finally { + setLoadingHistory(false); + } + }; + + const fetchOrgType = async () => { + try { + const { data } = await axios.get('/api/admin/organization/profile', { + withCredentials: true + }); + setOrgType(data.organization_type); + } catch (err) { + console.error('Failed to load organization type:', err); + setOrgType(''); // Default to empty if can't fetch + } finally { + setLoadingOrgType(false); + } + }; + + const handleFileChange = (e) => { + const selectedFile = e.target.files[0]; + if (!selectedFile) return; + + if (!selectedFile.name.endsWith('.csv')) { + setError('Please select a CSV file'); + return; + } + + setFile(selectedFile); + setError(null); + setSuccess(null); + + // Parse CSV for preview + const reader = new FileReader(); + reader.onload = (event) => { + const text = event.target.result; + const lines = text.split('\n').filter(line => line.trim()); + + if (lines.length === 0) { + setError('CSV file is empty'); + setPreview([]); + return; + } + + // Parse header + const headers = lines[0].split(',').map(h => h.trim().toLowerCase()); + + // Validate required columns (grade_level required for K-12 schools only) + const requiredCols = orgType === 'K-12 School' + ? ['email', 'firstname', 'lastname', 'grade_level'] + : ['email', 'firstname', 'lastname']; + const missing = requiredCols.filter(col => !headers.includes(col)); + + if (missing.length > 0) { + setError(`Missing required columns: ${missing.join(', ')}`); + setPreview([]); + return; + } + + // Parse rows (limit preview to first 10) + const rows = []; + for (let i = 1; i < Math.min(lines.length, 11); i++) { + const values = lines[i].split(',').map(v => v.trim()); + const row = {}; + headers.forEach((header, idx) => { + row[header] = values[idx] || ''; + }); + + // Basic validation + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + let valid = emailRegex.test(row.email) && row.firstname && row.lastname; + + // For K-12 schools, grade_level is required and must be 9-12 + if (orgType === 'K-12 School') { + if (row.grade_level && row.grade_level.trim()) { + const gradeLevel = parseInt(row.grade_level); + valid = valid && !isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12; + } else { + valid = false; // Missing required grade_level + } + } else { + // For non-K12, grade_level is optional but if provided must be 9-12 + if (row.grade_level && row.grade_level.trim()) { + const gradeLevel = parseInt(row.grade_level); + valid = valid && !isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12; + } + } + + row.valid = valid; + + rows.push(row); + } + + setPreview(rows); + + if (lines.length > 11) { + setError(null); + } + }; + + reader.readAsText(selectedFile); + }; + + const handleUpload = async () => { + if (!file || preview.length === 0) { + setError('Please select a file first'); + return; + } + + setUploading(true); + setError(null); + setSuccess(null); + + try { + // Use the already-parsed preview data but parse entire file + const text = await file.text(); + const lines = text.split('\n').filter(line => line.trim()); + const headers = lines[0].split(',').map(h => h.trim().toLowerCase()); + + // Parse ALL rows (not just preview) + const students = []; + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(',').map(v => v.trim()); + const student = {}; + headers.forEach((header, idx) => { + student[header] = values[idx] || ''; + }); + + // Only include if has required fields + if (student.email && student.firstname && student.lastname) { + const studentData = { + email: student.email, + firstname: student.firstname, + lastname: student.lastname, + status: student.status || 'active' + }; + + // Include grade_level only if provided and valid + if (student.grade_level && student.grade_level.trim()) { + const gradeLevel = parseInt(student.grade_level); + if (!isNaN(gradeLevel) && gradeLevel >= 9 && gradeLevel <= 12) { + studentData.grade_level = gradeLevel; + } + } + + students.push(studentData); + } + } + + if (students.length === 0) { + setError('No valid students found in CSV'); + setUploading(false); + return; + } + + // Send parsed JSON to backend + const { data } = await axios.post('/api/admin/roster/upload', + { students }, + { withCredentials: true } + ); + + setSuccess(`Successfully added ${data.results.added} students, updated ${data.results.updated} existing students${data.results.errors.length > 0 ? `, ${data.results.errors.length} errors` : ''}`); + setFile(null); + setPreview([]); + + // Refresh history + fetchHistory(); + + // Reset file input + const fileInput = document.getElementById('roster-file-input'); + if (fileInput) fileInput.value = ''; + + } catch (err) { + setError(err.response?.data?.error || 'Failed to upload roster'); + } finally { + setUploading(false); + } + }; + + const downloadTemplate = () => { + const csv = orgType === 'K-12 School' + ? 'email,firstname,lastname,grade_level\nstudent1@example.com,John,Doe,11\nstudent2@example.com,Jane,Smith,12' + : 'email,firstname,lastname\nstudent1@example.com,John,Doe\nstudent2@example.com,Jane,Smith'; + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'roster_template.csv'; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + +
+
+

Roster Management

+

+ Upload student rosters and view upload history +

+
+ + {/* Tabs */} +
+ +
+ + {/* Upload Tab */} + {activeTab === 'upload' && ( +
+ {/* Instructions */} +
+

CSV Format Requirements

+
    +
  • • Required columns: email, firstname, lastname{orgType === 'K-12 School' && <>, grade_level}
  • + {orgType === 'K-12 School' ? ( +
  • • Grade level must be 9-12 (9th-12th grade)
  • + ) : ( +
  • • Optional column: grade_level (if provided, must be 9-12)
  • + )} +
  • • Header row must be included
  • +
  • • Email addresses must be valid format
  • +
  • • Duplicate emails will be skipped automatically
  • +
+ +
+ + {/* File Upload */} +
+ + + {error && ( +
+ +

{error}

+
+ )} + + {success && ( +
+ +

{success}

+
+ )} + + {preview.length > 0 && ( +
+

+ Preview ({preview.length} rows shown) +

+
+ + + + + + + + + + + + {preview.map((row, idx) => ( + + + + + + + + ))} + +
EmailFirst NameLast NameGradeStatus
{row.email}{row.firstname}{row.lastname}{row.grade_level} + {row.valid ? ( + Valid + ) : ( + Invalid + )} +
+
+ + +
+ )} +
+
+ )} + + {/* History Tab */} + {activeTab === 'history' && ( +
+ {loadingHistory ? ( +
+
+

Loading history...

+
+ ) : history.length === 0 ? ( +
+ +

No roster uploads yet

+
+ ) : ( +
+ + + + + + + + + + + + {history.map((upload) => ( + + + + + + + + ))} + +
+ Upload Date + + Students Added + + Already Existed + + Total Roster Size + + Change % +
+ {new Date(upload.uploaded_at).toLocaleString()} + + {upload.students_added} + + {upload.students_existing} + + {upload.total_students_after} + + 0 ? 'text-green-600' : 'text-gray-400' + }`}> + {upload.change_percentage > 0 ? '+' : ''}{upload.change_percentage}% + +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/Admin/Settings.js b/src/components/Admin/Settings.js new file mode 100644 index 0000000..b7bfcc6 --- /dev/null +++ b/src/components/Admin/Settings.js @@ -0,0 +1,945 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; +import { Building2, Calendar, Users, CreditCard, Save, Trash2, UserPlus, AlertCircle, CheckCircle } from 'lucide-react'; +import AdminLayout from './AdminLayout.js'; +import { useAdmin } from '../../contexts/AdminContext.js'; + +export default function Settings() { + const { admin } = useAdmin(); + const [activeTab, setActiveTab] = useState('profile'); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + + // Organization Profile State + const [orgProfile, setOrgProfile] = useState({ + organization_name: '', + organization_type: '', + address: '', + city: '', + state: '', + zip_code: '', + primary_contact_name: '', + primary_contact_email: '', + primary_contact_phone: '', + onboarding_delay_days: 14 + }); + + // Academic Calendar State + const [calendar, setCalendar] = useState({ + calendar_type: 'semester', // semester, quarter, trimester + fall_term_start_month: 8, + fall_term_start_day: 15, + fall_add_drop_deadline_days: 14, + winter_term_start_month: 1, + winter_term_start_day: 7, + winter_add_drop_deadline_days: 14, + spring_term_start_month: 3, + spring_term_start_day: 25, + spring_add_drop_deadline_days: 14, + summer_term_start_month: 6, + summer_term_start_day: 1, + summer_add_drop_deadline_days: 7 + }); + + // Admin Users State + const [adminUsers, setAdminUsers] = useState([]); + const [newAdmin, setNewAdmin] = useState({ + email: '', + firstname: '', + lastname: '', + role: 'staff_admin' + }); + const [showAddAdmin, setShowAddAdmin] = useState(false); + + // Billing State + const [billing, setBilling] = useState(null); + + useEffect(() => { + if (activeTab === 'profile') { + fetchOrgProfile(); + } else if (activeTab === 'calendar') { + fetchCalendar(); + } else if (activeTab === 'admins') { + fetchAdminUsers(); + } else if (activeTab === 'billing') { + fetchBilling(); + } + }, [activeTab]); + + const fetchOrgProfile = async () => { + setLoading(true); + try { + const { data } = await axios.get('/api/admin/organization/profile', { + withCredentials: true + }); + setOrgProfile(data); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load organization profile'); + } finally { + setLoading(false); + } + }; + + const saveOrgProfile = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + await axios.put('/api/admin/organization/profile', orgProfile, { + withCredentials: true + }); + setSuccess('Organization profile updated successfully'); + } catch (err) { + setError(err.response?.data?.error || 'Failed to update organization profile'); + } finally { + setLoading(false); + } + }; + + const fetchCalendar = async () => { + setLoading(true); + try { + const { data } = await axios.get('/api/admin/organization/calendar', { + withCredentials: true + }); + if (data) { + setCalendar(data); + } + } catch (err) { + setError(err.response?.data?.error || 'Failed to load academic calendar'); + } finally { + setLoading(false); + } + }; + + const saveCalendar = async () => { + setLoading(true); + setError(null); + setSuccess(null); + try { + await axios.put('/api/admin/organization/calendar', calendar, { + withCredentials: true + }); + setSuccess('Academic calendar updated successfully'); + } catch (err) { + setError(err.response?.data?.error || 'Failed to update academic calendar'); + } finally { + setLoading(false); + } + }; + + const fetchAdminUsers = async () => { + setLoading(true); + try { + const { data } = await axios.get('/api/admin/organization/admins', { + withCredentials: true + }); + setAdminUsers(Array.isArray(data) ? data : []); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load admin users'); + setAdminUsers([]); + } finally { + setLoading(false); + } + }; + + const addAdmin = async () => { + if (!newAdmin.email || !newAdmin.firstname || !newAdmin.lastname) { + setError('All fields are required'); + return; + } + + setLoading(true); + setError(null); + setSuccess(null); + try { + await axios.post('/api/admin/organization/admins', newAdmin, { + withCredentials: true + }); + setSuccess('Admin user added successfully'); + setNewAdmin({ email: '', firstname: '', lastname: '', role: 'staff_admin' }); + setShowAddAdmin(false); + fetchAdminUsers(); + } catch (err) { + setError(err.response?.data?.error || 'Failed to add admin user'); + } finally { + setLoading(false); + } + }; + + const removeAdmin = async (userId) => { + if (!window.confirm('Are you sure you want to remove this admin?')) return; + + setLoading(true); + setError(null); + setSuccess(null); + try { + await axios.delete(`/api/admin/organization/admins/${userId}`, { + withCredentials: true + }); + setSuccess('Admin user removed successfully'); + fetchAdminUsers(); + } catch (err) { + setError(err.response?.data?.error || 'Failed to remove admin user'); + } finally { + setLoading(false); + } + }; + + const fetchBilling = async () => { + setLoading(true); + try { + const { data } = await axios.get('/api/admin/organization/billing', { + withCredentials: true + }); + setBilling(data); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load billing information'); + } finally { + setLoading(false); + } + }; + + return ( + +
+
+

Settings

+

+ Manage your organization settings and configuration +

+
+ + {/* Tabs */} +
+ +
+ + {/* Alert Messages */} + {error && ( +
+ +

{error}

+
+ )} + + {success && ( +
+ +

{success}

+
+ )} + + {/* Organization Profile Tab */} + {activeTab === 'profile' && ( +
+ {loading && !orgProfile.organization_name ? ( +
+
+
+ ) : ( +
+
+
+ + setOrgProfile({ ...orgProfile, organization_name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+ +
+ + +
+ +
+ + setOrgProfile({ ...orgProfile, address: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+ +
+ + setOrgProfile({ ...orgProfile, city: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+ +
+ + setOrgProfile({ ...orgProfile, state: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+ +
+ + setOrgProfile({ ...orgProfile, zip_code: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+
+ +
+

Primary Contact

+
+
+ + setOrgProfile({ ...orgProfile, primary_contact_name: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+ +
+ + setOrgProfile({ ...orgProfile, primary_contact_email: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+ +
+ + setOrgProfile({ ...orgProfile, primary_contact_phone: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+
+
+ + {orgProfile.organization_type === 'K-12 School' && ( +
+

Student Onboarding

+
+

+ 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. +

+
+
+ + setOrgProfile({ ...orgProfile, onboarding_delay_days: parseInt(e.target.value) || 14 })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +

+ 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. +

+
+
+ )} + +
+ +
+
+ )} +
+ )} + + {/* Academic Calendar Tab */} + {activeTab === 'calendar' && ( +
+ {loading && !calendar.calendar_type ? ( +
+
+
+ ) : ( +
+
+

+ Configure your institution's academic calendar. This information helps AptivaAI align roster updates with your academic schedule. +

+
+ +
+ + +
+ +
+

Academic Year

+
+
+ + setCalendar({ ...calendar, academic_year_start: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+
+ + setCalendar({ ...calendar, academic_year_end: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+
+
+ +
+

Academic Term Schedule

+

+ Enter recurring term start dates (month/day) and add/drop deadlines. Roster uploads must occur AFTER the add/drop deadline each term. +

+ + {/* Fall Term */} +
+

Fall Term

+
+
+ +
+ + +
+
+
+ + setCalendar({ ...calendar, fall_add_drop_deadline_days: parseInt(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +

Roster uploads must occur after this deadline

+
+
+
+ + {/* Winter Term (Quarter/Trimester only) */} + {(calendar.calendar_type === 'quarter' || calendar.calendar_type === 'trimester') && ( +
+

Winter Term

+
+
+ +
+ + +
+
+
+ + setCalendar({ ...calendar, winter_add_drop_deadline_days: parseInt(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +

Roster uploads must occur after this deadline

+
+
+
+ )} + + {/* Spring Term (Semester/Trimester/Quarter) */} +
+

Spring Term

+
+
+ +
+ + +
+
+
+ + setCalendar({ ...calendar, spring_add_drop_deadline_days: parseInt(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +

Roster uploads must occur after this deadline

+
+
+
+ + {/* Summer Term (Quarter only) */} + {calendar.calendar_type === 'quarter' && ( +
+

Summer Term

+
+
+ +
+ + +
+
+
+ + setCalendar({ ...calendar, summer_add_drop_deadline_days: parseInt(e.target.value) })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +

Roster uploads must occur after this deadline

+
+
+
+ )} +
+ +
+ +
+
+ )} +
+ )} + + {/* Admin Users Tab */} + {activeTab === 'admins' && ( +
+ {loading && adminUsers.length === 0 ? ( +
+
+
+ ) : ( +
+
+

Admin Users

+ {admin?.isSuperAdmin && ( + + )} +
+ + {showAddAdmin && ( +
+

Add New Admin

+
+
+ + setNewAdmin({ ...newAdmin, email: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+
+ + +

+ Super Admin access is managed by AptivaAI. Contact support for additional Super Admins. +

+
+
+ + setNewAdmin({ ...newAdmin, firstname: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+
+ + setNewAdmin({ ...newAdmin, lastname: e.target.value })} + className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-aptiva focus:border-aptiva" + /> +
+
+
+ + +
+
+ )} + +
+ + + + + + + + {admin?.isSuperAdmin && ( + + )} + + + + {adminUsers.map((user) => ( + + + + + + {admin?.isSuperAdmin && ( + + )} + + ))} + +
NameEmailRoleAddedActions
+ {user.firstname} {user.lastname} + + {user.email} + + + {user.role === 'super_admin' ? 'Super Admin' : 'Staff Admin'} + + + {new Date(user.created_at).toLocaleDateString()} + + {user.user_id !== admin.userId && ( + + )} +
+
+
+ )} +
+ )} + + {/* Billing Tab */} + {activeTab === 'billing' && ( +
+ {loading && !billing ? ( +
+
+
+ ) : billing ? ( +
+
+
+

Subscription Plan

+

{billing.subscription_plan}

+
+
+

Status

+

+ + {billing.subscription_status} + +

+
+
+

Discount Eligible

+

+ + {billing.discount_eligible ? 'Yes' : 'No'} + +

+
+
+ +
+

Billing Information

+
+
+
Billing Contact
+
{billing.billing_contact_name || 'Not set'}
+
+
+
Billing Email
+
{billing.billing_contact_email || 'Not set'}
+
+
+
Last Invoice Date
+
+ {billing.last_invoice_date ? new Date(billing.last_invoice_date).toLocaleDateString() : 'N/A'} +
+
+
+
Last Invoice Amount
+
+ {billing.last_invoice_amount ? `$${parseFloat(billing.last_invoice_amount).toFixed(2)}` : 'N/A'} +
+
+
+
+ +
+

+ For billing changes or questions, please contact your AptivaAI account representative. +

+
+
+ ) : ( +
+ +

No billing information available

+
+ )} +
+ )} +
+
+ ); +} diff --git a/src/components/Admin/StudentDetail.js b/src/components/Admin/StudentDetail.js new file mode 100644 index 0000000..29c5401 --- /dev/null +++ b/src/components/Admin/StudentDetail.js @@ -0,0 +1,576 @@ +import { useState, useEffect } from 'react'; +import { useParams, useNavigate, Link } from 'react-router-dom'; +import axios from 'axios'; +import AdminLayout from './AdminLayout.js'; +import { Button } from '../ui/button.js'; +import { ArrowLeft, CheckCircle, XCircle, Lock, Activity, ChevronDown, ChevronUp } from 'lucide-react'; +import RiaSecChart from '../RiaSecChart.js'; + +export default function StudentDetail() { + const { studentId } = useParams(); + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showDeactivateModal, setShowDeactivateModal] = useState(false); + const [deactivateReason, setDeactivateReason] = useState('graduated'); + const [expandedCareerProfile, setExpandedCareerProfile] = useState(null); + const [expandedCollegeProfile, setExpandedCollegeProfile] = useState(null); + + useEffect(() => { + const fetchStudent = async () => { + try { + const { data } = await axios.get(`/api/admin/students/${studentId}`, { + withCredentials: true + }); + setData(data); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load student details'); + } finally { + setLoading(false); + } + }; + + fetchStudent(); + }, [studentId]); + + const handleResetPassword = async () => { + if (!window.confirm('Send password reset email to this student?')) return; + + try { + await axios.post(`/api/admin/students/${studentId}/reset-password`, {}, { + withCredentials: true + }); + alert('Password reset email sent successfully'); + } catch (err) { + alert(err.response?.data?.error || 'Failed to send password reset'); + } + }; + + const handleDeactivateClick = () => { + setShowDeactivateModal(true); + }; + + const handleDeactivateConfirm = async () => { + try { + await axios.post(`/api/admin/students/${studentId}/deactivate`, { + status: deactivateReason + }, { + withCredentials: true + }); + alert(`Student marked as ${deactivateReason}`); + navigate('/admin/students'); + } catch (err) { + alert(err.response?.data?.error || 'Failed to update student status'); + } + }; + + const formatDate = (dateString) => { + if (!dateString) return 'Never'; + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }; + + if (loading) { + return ( + +
+
+
+
+ ); + } + + if (error || !data) { + return ( + +
+
{error || 'Student not found'}
+
+
+ ); + } + + const { student, privacy, careers, riasecScores, careerProfiles, collegeProfiles, roadmapMilestones } = data; + + return ( + +
+ {/* Header */} +
+ + + Back to Students + + +
+
+

+ {student.firstname} {student.lastname} +

+

{student.email}

+
+ + {student.enrollment_status} + + + Enrolled: {formatDate(student.invitation_accepted_at || student.enrollment_date)} + + + Last Active: {formatDate(student.last_login)} + +
+
+ +
+ + +
+
+
+ + {/* Deactivate Modal */} + {showDeactivateModal && ( +
setShowDeactivateModal(false)}> +
e.stopPropagation()}> +
+

+ Deactivate Student Account +

+

+ This will revoke the student's access immediately. Please select the reason: +

+
+ + + + +
+
+ + +
+
+
+
+ )} + + {/* Activity Overview */} +
+

+ + Activity Overview +

+
+
+ {student.inventory_completed_at ? ( + + ) : ( + + )} + Interest Inventory +
+
+ {student.career_comparison_count > 0 ? ( + + ) : ( + + )} + + {student.career_comparison_count || 0} Career Comparison + +
+
+ {student.career_profiles_count > 0 ? ( + + ) : ( + + )} + + {student.career_profiles_count || 0} Career Profile{student.career_profiles_count !== 1 ? 's' : ''} + +
+
+ {student.college_profiles_count > 0 ? ( + + ) : ( + + )} + + {student.college_profiles_count || 0} College Profile{student.college_profiles_count !== 1 ? 's' : ''} + +
+
+ {student.financial_profiles_count > 0 ? ( + + ) : ( + + )} + Financial Profile +
+
+ {student.roadmaps_count > 0 ? ( + + ) : ( + + )} + Career Roadmap +
+
+
+ + {/* Careers Explored */} +
+

Careers Explored

+ {!privacy.share_career_exploration ? ( +
+ + + Student has not shared career exploration data with your organization. + +
+ ) : careers && careers.length > 0 ? ( +
+ {careers.map((career, index) => ( +
+
+

{career.career_name}

+
+ + {career.view_count} {career.view_count === 1 ? 'view' : 'views'} + +
+ ))} +
+ ) : ( +

No careers explored yet

+ )} +
+ + {/* Interest Inventory Results (RIASEC Chart) */} +
+

Interest Inventory Results

+ {!privacy.share_interest_inventory ? ( +
+ + + Student has not shared interest inventory results with your organization. + +
+ ) : riasecScores && riasecScores.length > 0 ? ( +
+ +
+ ) : ( +

Interest inventory not completed yet

+ )} +
+ + {/* Career Profiles */} +
+

Career Profiles

+ {!privacy.share_career_profiles ? ( +
+ + + Student has not shared career profiles with your organization. + +
+ ) : careerProfiles && careerProfiles.length > 0 ? ( +
+ {careerProfiles.map((profile) => ( +
+ + {expandedCareerProfile === profile.id && ( +
+ {profile.scenario_title && ( +

+ Scenario: {profile.scenario_title} +

+ )} +

+ Status: {profile.status || 'N/A'} +

+ {profile.currently_working && ( +

+ Currently Working: {profile.currently_working} +

+ )} + {profile.start_date && ( +

+ Start Date: {formatDate(profile.start_date)} +

+ )} + {profile.retirement_start_date && ( +

+ Retirement Date: {formatDate(profile.retirement_start_date)} +

+ )} + {profile.college_enrollment_status && ( +

+ College Status: {profile.college_enrollment_status} +

+ )} +
+ )} +
+ ))} +
+ ) : ( +

No career profiles created yet

+ )} +
+ + {/* College Profiles */} +
+

College Profiles

+ {!privacy.share_college_profiles ? ( +
+ + + Student has not shared college profiles with your organization. + +
+ ) : collegeProfiles && collegeProfiles.length > 0 ? ( +
+ {collegeProfiles.map((profile) => ( +
+ + {expandedCollegeProfile === profile.id && ( +
+ {profile.selected_program && ( +

+ Program: {profile.selected_program} +

+ )} + {profile.program_type && ( +

+ Program Type: {profile.program_type} +

+ )} + {profile.college_enrollment_status && ( +

+ Enrollment Status: {profile.college_enrollment_status} +

+ )} + {profile.tuition && ( +

+ Tuition: ${parseFloat(profile.tuition).toLocaleString()} +

+ )} + {profile.expected_graduation && ( +

+ Expected Graduation: {formatDate(profile.expected_graduation)} +

+ )} + {profile.expected_salary && ( +

+ Expected Salary: ${parseFloat(profile.expected_salary).toLocaleString()} +

+ )} +
+ )} +
+ ))} +
+ ) : ( +

No college profiles created yet

+ )} +
+ + {/* Financial Profile */} +
+

Financial Profile

+
+
+ + + 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. + +
+ ) : roadmapMilestones && roadmapMilestones.length > 0 ? ( +
+ {roadmapMilestones.map((milestone) => ( +
+
+ {milestone.status === 'completed' ? ( + + ) : ( +
+ )} +
+
+

{milestone.title}

+ {milestone.description && ( +

{milestone.description}

+ )} +

+ Career: {milestone.career_name} | Target: {formatDate(milestone.date)} +

+
+
+ ))} +
+ ) : ( +

No career roadmap milestones created yet

+ )} +
+ + {/* Privacy Settings */} +
+

Privacy Settings

+

What this student has chosen to share with your organization:

+
+
+ {privacy.share_career_exploration ? ( + + ) : ( + + )} + Career Exploration Data +
+
+ {privacy.share_interest_inventory ? ( + + ) : ( + + )} + Interest Inventory Results +
+
+ {privacy.share_career_profiles ? ( + + ) : ( + + )} + Career Profiles +
+
+ {privacy.share_college_profiles ? ( + + ) : ( + + )} + College Profiles +
+
+ {privacy.share_financial_profile ? ( + + ) : ( + + )} + Financial Profile +
+
+ {privacy.share_roadmap ? ( + + ) : ( + + )} + Career Roadmap +
+
+
+
+ + ); +} diff --git a/src/components/Admin/StudentList.js b/src/components/Admin/StudentList.js new file mode 100644 index 0000000..9e08622 --- /dev/null +++ b/src/components/Admin/StudentList.js @@ -0,0 +1,402 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import axios from 'axios'; +import AdminLayout from './AdminLayout.js'; +import { Input } from '../ui/input.js'; +import { Button } from '../ui/button.js'; +import { Search, UserPlus, ChevronLeft, ChevronRight } from 'lucide-react'; + +export default function StudentList() { + const [allStudents, setAllStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(''); + const [status, setStatus] = useState(''); + const [activity, setActivity] = useState(''); + const [engagement, setEngagement] = useState(''); + const [page, setPage] = useState(0); + const limit = 50; + + const fetchStudents = async () => { + try { + setLoading(true); + const params = new URLSearchParams(); + if (status) params.append('status', status); + + const { data } = await axios.get(`/api/admin/students?${params}`, { + withCredentials: true + }); + setAllStudents(data.students || []); + setError(null); + } catch (err) { + setError(err.response?.data?.error || 'Failed to load students'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchStudents(); + }, [status]); + + // Client-side filtering based on search, activity, and engagement + const filteredStudents = allStudents.filter((student) => { + // Search filter + if (search) { + const searchLower = search.toLowerCase(); + const fullName = `${student.firstname} ${student.lastname}`.toLowerCase(); + const email = (student.email || '').toLowerCase(); + if (!fullName.includes(searchLower) && !email.includes(searchLower)) { + return false; + } + } + + // Activity filter (based on last_login) + if (activity) { + const now = new Date(); + const lastLogin = student.last_login ? new Date(student.last_login) : null; + + if (activity === 'today') { + if (!lastLogin || (now - lastLogin) > 24 * 60 * 60 * 1000) return false; + } else if (activity === 'week') { + if (!lastLogin || (now - lastLogin) > 7 * 24 * 60 * 60 * 1000) return false; + } else if (activity === 'month') { + if (!lastLogin || (now - lastLogin) > 30 * 24 * 60 * 60 * 1000) return false; + } else if (activity === 'inactive_30_90') { + const daysSince = lastLogin ? (now - lastLogin) / (24 * 60 * 60 * 1000) : 9999; + if (daysSince < 30 || daysSince > 90) return false; + } else if (activity === 'inactive_90plus') { + const daysSince = lastLogin ? (now - lastLogin) / (24 * 60 * 60 * 1000) : 9999; + if (daysSince < 90) return false; + } else if (activity === 'never') { + if (lastLogin) return false; + } + } + + // Engagement filter + if (engagement) { + if (engagement === 'inventory' && !student.inventory_completed_at) return false; + if (engagement === 'career_profiles' && student.career_profiles_count === 0) return false; + if (engagement === 'college_profiles' && student.college_profiles_count === 0) return false; + if (engagement === 'financial_profile' && student.financial_profiles_count === 0) return false; + if (engagement === 'roadmap' && student.roadmaps_count === 0) return false; + if (engagement === 'no_activity') { + // No activity means they haven't used any features (but may have logged in) + if (student.inventory_completed_at || student.career_profiles_count > 0 || + student.college_profiles_count > 0 || student.financial_profiles_count > 0 || + student.roadmaps_count > 0) { + return false; + } + } + } + + return true; + }); + + // Paginate filtered results + const paginatedStudents = filteredStudents.slice(page * limit, (page + 1) * limit); + + const handleSearch = (e) => { + e.preventDefault(); + // Search happens automatically via filteredStudents + }; + + // Reset to page 0 when search term changes + useEffect(() => { + setPage(0); + }, [search]); + + const formatDate = (dateString) => { + if (!dateString) return 'Never'; + + // Extract just the date portion (YYYY-MM-DD) if it's a full timestamp + // This prevents timezone conversion issues + const datePart = dateString.split('T')[0]; + + // Parse as local date to avoid timezone shift + const date = new Date(datePart + 'T00:00:00'); + + // Check for invalid date + if (isNaN(date.getTime())) return 'Invalid Date'; + + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + }; + + const getStatusBadge = (status) => { + const styles = { + active: 'bg-green-100 text-green-800', + graduated: 'bg-blue-100 text-blue-800', + withdrawn: 'bg-red-100 text-red-800', + transferred: 'bg-yellow-100 text-yellow-800' + }; + return styles[status] || 'bg-gray-100 text-gray-800'; + }; + + return ( + +
+
+
+

Students

+

+ Manage your organization's student roster +

+
+ + + +
+ + {/* Filters */} +
+
+
+
+
+ + setSearch(e.target.value)} + className="pl-10" + /> +
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + {/* Students Table */} + {error && ( +
+
{error}
+
+ )} + + {loading ? ( +
+
+

Loading students...

+
+ ) : filteredStudents.length === 0 ? ( +
+

No students found

+
+ ) : ( +
+
+

+ Showing {filteredStudents.length} student{filteredStudents.length !== 1 ? 's' : ''} +

+ +
+ + + + + + + + + + + + + {paginatedStudents.map((student) => ( + + + + + + + + + ))} + +
+ Name + + Email + + Status + + Last Login + + Enrolled + + Actions +
+
+ {student.firstname} {student.lastname} +
+
+
{student.email}
+
+ + {student.enrollment_status} + + + {formatDate(student.last_login)} + + {formatDate(student.enrollment_date)} + + + View Details + +
+ + {/* Pagination */} +
+
+ + +
+
+
+

+ Showing {page * limit + 1} to{' '} + {Math.min((page + 1) * limit, filteredStudents.length)} of{' '} + {filteredStudents.length} students +

+
+
+ +
+
+
+
+ )} +
+
+ ); +} 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. +

+
+
+ ); +} diff --git a/src/components/InviteSignup.js b/src/components/InviteSignup.js new file mode 100644 index 0000000..fab4f6b --- /dev/null +++ b/src/components/InviteSignup.js @@ -0,0 +1,93 @@ +import React, { useState, useEffect } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import SignUp from './SignUp.js'; + +function InviteSignup() { + const { token } = useParams(); + const navigate = useNavigate(); + const [validating, setValidating] = useState(true); + const [inviteData, setInviteData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + console.log('[InviteSignup] Component mounted, token:', token); + + if (!token) { + setError('Invalid invitation link'); + setValidating(false); + return; + } + + // Validate the invitation token + fetch('/api/validate-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }) + .then(res => res.json()) + .then(data => { + console.log('[InviteSignup] Validation response:', data); + if (data.valid) { + setInviteData({ + email: data.email, + userId: data.userId, + organizationId: data.organizationId, + token: token + }); + setError(null); + } else { + setError(data.error || 'Invalid or expired invitation link'); + } + }) + .catch(err => { + console.error('[InviteSignup] Error validating invitation:', err); + setError('Failed to validate invitation. Please try again or contact your administrator.'); + }) + .finally(() => { + setValidating(false); + }); + }, [token]); + + // Show loading state + if (validating) { + return ( +
+
+
+

Validating invitation...

+
+
+ ); + } + + // Show error state + if (error) { + return ( +
+
+
+
+ + + +
+

Invitation Error

+

{error}

+ +
+
+
+ ); + } + + // Valid invitation - render SignUp with pre-filled data + console.log('[InviteSignup] Rendering SignUp with inviteData:', inviteData); + return ; +} + +export default InviteSignup; diff --git a/src/components/LinkSecondaryEmail.js b/src/components/LinkSecondaryEmail.js new file mode 100644 index 0000000..7e823b4 --- /dev/null +++ b/src/components/LinkSecondaryEmail.js @@ -0,0 +1,227 @@ +import { useState, useEffect, useRef } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; +import axios from 'axios'; + +export default function LinkSecondaryEmail() { + 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 secondary email directly - if not authenticated, backend will return 401 + await axios.post('/api/link-secondary-email', { + 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(`/link-secondary-email?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 get invitation details + 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 !== true) { + // This invitation is for existing user (same email), redirect to invite-response + navigate(`/invite-response?token=${token}`, { replace: true }); + return; + } + + // Token is valid for new user invitation + setTokenData({ + token, + organizationId: payload.organizationId, + email: payload.email, + userId: payload.userId // This is the shell user_id + }); + 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 handleCreateNewAccount = () => { + // Redirect to signup with the invitation token + navigate(`/signup?invite=${tokenData.token}`, { replace: true }); + }; + + if (loading) { + return ( +
+
+
+

Validating invitation...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+
+ + + +
+

Invalid Invitation

+

{error}

+ +
+
+
+ ); + } + + return ( +
+
+
+

Link Your Account

+

You were invited as: {tokenData.email}

+
+ +
+

+ If you already have an AptivaAI account with a different email address, you can link this invitation to your existing account. +

+
+ + {error && ( +
+

{error}

+
+ )} + +
+ {/* Option 1: Link to Existing Account */} +
+

Link to Your Existing Account

+
    +
  • + + + + Keep all your existing data and progress +
  • +
  • + + + + Access your account using either email address +
  • +
  • + + + + Gain premium access through your organization +
  • +
+ +
+ + {/* Option 2: Create New Account */} +
+

Create a New Account

+
    +
  • + + + + Start fresh with a new profile +
  • +
  • + + + + Keep personal and school accounts separate +
  • +
+ +
+
+ +

+ Questions? Contact your administrator for help. +

+
+
+ ); +} diff --git a/src/components/PremiumOnboarding/CareerOnboarding.js b/src/components/PremiumOnboarding/CareerOnboarding.js index a827a3c..a0c04fe 100644 --- a/src/components/PremiumOnboarding/CareerOnboarding.js +++ b/src/components/PremiumOnboarding/CareerOnboarding.js @@ -112,6 +112,7 @@ function handleSubmit() { setData(prev => ({ ...prev, career_name : selectedCareerTitle, + soc_code : careerObj?.soc_code || prev.soc_code || '', college_enrollment_status : collegeStatus, currently_working : currentlyWorking, inCollege, diff --git a/src/components/PremiumOnboarding/OnboardingContainer.js b/src/components/PremiumOnboarding/OnboardingContainer.js index 9427c9d..1a2cf69 100644 --- a/src/components/PremiumOnboarding/OnboardingContainer.js +++ b/src/components/PremiumOnboarding/OnboardingContainer.js @@ -164,7 +164,11 @@ export default function OnboardingContainer() { async function handleFinalSubmit() { try { // 1) scenario upsert - const scenarioPayload = { ...careerData, id: careerData.career_profile_id || undefined }; + const scenarioPayload = { + ...careerData, + id: careerData.career_profile_id || undefined, + career_soc_code: careerData.soc_code || careerData.career_soc_code || undefined + }; const scenarioRes = await authFetch('/api/premium/career-profile', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(scenarioPayload) @@ -214,6 +218,14 @@ export default function OnboardingContainer() { await clearDraft(); localStorage.removeItem(POINTER_KEY); sessionStorage.setItem('suppressOnboardingGuard', '1'); + + // Mark onboarding as completed for roster students + try { + await authFetch('/api/onboarding-completed', { method: 'POST' }); + } catch (markErr) { + console.warn('Failed to mark onboarding complete:', markErr); + } + navigate(`/career-roadmap/${finalId}`, { state: { fromOnboarding: true, selectedCareer: picked } }); } catch (err) { console.error('Error in final submit =>', err); diff --git a/src/components/PrivacyPolicy.js b/src/components/PrivacyPolicy.js index 8148f06..50a4deb 100644 --- a/src/components/PrivacyPolicy.js +++ b/src/components/PrivacyPolicy.js @@ -49,15 +49,13 @@ function PrivacyPolicy() {

Information We Collect

    -
  • 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{' '} .

    diff --git a/src/components/PrivacySettings.js b/src/components/PrivacySettings.js new file mode 100644 index 0000000..317738b --- /dev/null +++ b/src/components/PrivacySettings.js @@ -0,0 +1,238 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; + +export default function PrivacySettings() { + const [organizations, setOrganizations] = useState([]); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const [successMessage, setSuccessMessage] = useState(''); + + useEffect(() => { + fetchPrivacySettings(); + }, []); + + const fetchPrivacySettings = async () => { + try { + setLoading(true); + const { data } = await axios.get('/api/privacy-settings', { + withCredentials: true + }); + setOrganizations(data.organizations || []); + setError(null); + } catch (err) { + console.error('[PrivacySettings] Error loading settings:', err); + setError('Failed to load privacy settings'); + } finally { + setLoading(false); + } + }; + + const updateSetting = async (orgId, field, value) => { + try { + setSaving(true); + setSuccessMessage(''); + setError(null); + + // Find the organization's current settings + const org = organizations.find(o => o.organization_id === orgId); + if (!org) return; + + const updatedSettings = { + ...org.settings, + [field]: value + }; + + await axios.post('/api/privacy-settings', { + organization_id: orgId, + ...updatedSettings + }, { + withCredentials: true + }); + + // Update local state + setOrganizations(orgs => orgs.map(o => + o.organization_id === orgId + ? { ...o, settings: updatedSettings } + : o + )); + + setSuccessMessage('Settings updated successfully'); + setTimeout(() => setSuccessMessage(''), 3000); + } catch (err) { + console.error('[PrivacySettings] Error updating settings:', err); + setError('Failed to update privacy settings'); + } finally { + setSaving(false); + } + }; + + if (loading) { + return ( +
    +
    +
    +
    +
    +
    +
    + ); + } + + if (organizations.length === 0) { + return ( +
    +
    +

    Privacy Settings

    +
    +

    + You are not enrolled in any organizations. Privacy settings are only available for students enrolled through organizations. +

    +
    +
    +
    + ); + } + + return ( +
    +
    +
    +

    Privacy Settings

    +

    + Control what information you share with your educational organizations. Your privacy is important to us - all settings default to private. +

    +
    + + {error && ( +
    +
    {error}
    +
    + )} + + {successMessage && ( +
    +
    {successMessage}
    +
    + )} + + {organizations.map((org) => ( +
    +

    + {org.organization_name} +

    +

    + Choose what data you want to share with {org.organization_name}. You can change these settings at any time. +

    + +
    + updateSetting(org.organization_id, 'share_career_exploration', value)} + disabled={saving} + /> + + updateSetting(org.organization_id, 'share_interest_inventory', value)} + disabled={saving} + /> + + updateSetting(org.organization_id, 'share_career_profiles', value)} + disabled={saving} + /> + + updateSetting(org.organization_id, 'share_college_profiles', value)} + disabled={saving} + /> + + + + updateSetting(org.organization_id, 'share_roadmap', value)} + disabled={saving} + /> +
    +
    + ))} + +
    +

    How Your Data is Shared

    +

    + 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. +

    +
    +
    +
    + ); +} + +function PrivacyInfoBox({ label, description }) { + return ( +
    +
    +
    + {label} +
    +

    {description}

    +
    +
    + + + +
    +
    + ); +} + +function PrivacyToggle({ label, description, checked, onChange, disabled }) { + return ( +
    +
    + +

    {description}

    +
    +
    + +
    +
    + ); +} diff --git a/src/components/PrivacySettingsModal.js b/src/components/PrivacySettingsModal.js new file mode 100644 index 0000000..fc587ec --- /dev/null +++ b/src/components/PrivacySettingsModal.js @@ -0,0 +1,237 @@ +import { useState, useEffect } from 'react'; +import axios from 'axios'; + +export default function PrivacySettingsModal({ isOpen, onClose }) { + const [organizations, setOrganizations] = useState([]); + const [currentOrgIndex, setCurrentOrgIndex] = useState(0); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [settings, setSettings] = useState({ + share_career_exploration: false, + share_interest_inventory: false, + share_career_profiles: false, + share_college_profiles: false, + share_financial_profile: false, + share_roadmap: false + }); + + useEffect(() => { + if (isOpen) { + fetchOrganizations(); + } + }, [isOpen]); + + const fetchOrganizations = async () => { + try { + setLoading(true); + const { data } = await axios.get('/api/privacy-settings', { + withCredentials: true + }); + if (data.organizations && data.organizations.length > 0) { + setOrganizations(data.organizations); + // Load settings for first org + setSettings(data.organizations[0].settings || { + share_career_exploration: false, + share_interest_inventory: false, + share_career_profiles: false, + share_college_profiles: false, + share_financial_profile: false, + share_roadmap: false + }); + } + } catch (err) { + console.error('[PrivacySettingsModal] Error loading organizations:', err); + } finally { + setLoading(false); + } + }; + + const handleSave = async () => { + if (organizations.length === 0) return; + + try { + setSaving(true); + const currentOrg = organizations[currentOrgIndex]; + + await axios.post('/api/privacy-settings', { + organization_id: currentOrg.organization_id, + ...settings + }, { + withCredentials: true + }); + + // If there are more organizations, move to next + if (currentOrgIndex < organizations.length - 1) { + const nextIndex = currentOrgIndex + 1; + setCurrentOrgIndex(nextIndex); + setSettings(organizations[nextIndex].settings || { + share_career_exploration: false, + share_interest_inventory: false, + share_career_profiles: false, + share_college_profiles: false, + share_financial_profile: false, + share_roadmap: false + }); + } else { + // All done, close modal + onClose(); + } + } catch (err) { + console.error('[PrivacySettingsModal] Error saving settings:', err); + alert('Failed to save privacy settings. Please try again.'); + } finally { + setSaving(false); + } + }; + + const toggleSetting = (key) => { + setSettings(prev => ({ + ...prev, + [key]: !prev[key] + })); + }; + + if (!isOpen) return null; + + if (loading) { + return ( +
    +
    +
    +
    +
    +
    +
    + ); + } + + if (organizations.length === 0) { + return null; // No organizations, don't show modal + } + + const currentOrg = organizations[currentOrgIndex]; + const isLastOrg = currentOrgIndex === organizations.length - 1; + + return ( +
    +
    +
    +

    + Privacy Settings +

    +

    + 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. +

    +
    + +
    + toggleSetting('share_career_exploration')} + /> + + toggleSetting('share_interest_inventory')} + /> + + toggleSetting('share_career_profiles')} + /> + + toggleSetting('share_college_profiles')} + /> + + + + toggleSetting('share_roadmap')} + /> +
    + +
    + +
    +
    +
    +
    + ); +} + +function PrivacyToggleItem({ label, description, checked, onChange }) { + return ( +
    +
    + +

    {description}

    +
    +
    + +
    +
    + ); +} + +function PrivacyInfoItem({ label, description }) { + return ( +
    +
    +
    + {label} +
    +

    {description}

    +
    +
    + + + +
    +
    + ); +} diff --git a/src/components/RiaSecChart.js b/src/components/RiaSecChart.js index ff104ad..e635592 100644 --- a/src/components/RiaSecChart.js +++ b/src/components/RiaSecChart.js @@ -1,6 +1,14 @@ -import React from 'react'; import { Bar } from 'react-chartjs-2'; +const RIASEC_DESCRIPTIONS = { + 'Realistic': 'Prefers hands-on work with tools, machines, plants, or animals. Values practical tasks and tangible results.', + 'Investigative': 'Enjoys analyzing information, solving problems, and conducting research. Values intellectual challenges.', + 'Artistic': 'Prefers creative and expressive activities. Values self-expression, originality, and aesthetics.', + 'Social': 'Enjoys helping, teaching, and working with people. Values relationships and making a positive impact.', + 'Enterprising': 'Prefers leading, persuading, and managing others. Values achievement, influence, and business success.', + 'Conventional': 'Prefers organized, structured tasks with clear procedures. Values accuracy, efficiency, and order.' +}; + function RiaSecChart({ riaSecScores }) { const chartData = { labels: riaSecScores.map(score => score.area), @@ -17,6 +25,7 @@ function RiaSecChart({ riaSecScores }) { const options = { responsive: true, + maintainAspectRatio: true, plugins: { title: { display: true, @@ -34,9 +43,27 @@ function RiaSecChart({ riaSecScores }) { }; return ( -
    -

    RIASEC Scores

    - +
    +
    +

    RIASEC Scores

    + +
    +
    +

    Interest Area Descriptions

    +
    + {riaSecScores.map((score) => ( +
    +
    + {score.area} + {score.score} +
    +

    + {RIASEC_DESCRIPTIONS[score.area]} +

    +
    + ))} +
    +
    ); } diff --git a/src/components/SignIn.js b/src/components/SignIn.js index 2a27ba0..40a5949 100644 --- a/src/components/SignIn.js +++ b/src/components/SignIn.js @@ -1,5 +1,5 @@ import React, { useRef, useState, useEffect, useContext } from 'react'; -import { Link, useNavigate, useLocation } from 'react-router-dom'; +import { Link, useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { ProfileCtx } from '../App.js'; import * as safeLocal from '../utils/safeLocal.js'; @@ -12,6 +12,7 @@ function SignIn({ setIsAuthenticated, setUser }) { const [showSessionExpiredMsg, setShowSessionExpiredMsg] = useState(false); const [showConsent, setShowConsent] = useState(false); const location = useLocation(); + const [searchParams] = useSearchParams(); useEffect(() => { const query = new URLSearchParams(location.search); @@ -105,7 +106,13 @@ function SignIn({ setIsAuthenticated, setUser }) { setIsAuthenticated(true); setUser(minimalUser); - navigate('/signin-landing'); + // Check for redirect parameter (used by invitation flow) + const redirectTo = searchParams.get('redirect'); + if (redirectTo) { + navigate(decodeURIComponent(redirectTo)); + } else { + navigate('/signin-landing'); + } } catch (err) { setError(err.message || 'Sign-in failed'); } diff --git a/src/components/SignUp.js b/src/components/SignUp.js index c86752c..56d25ad 100644 --- a/src/components/SignUp.js +++ b/src/components/SignUp.js @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { Button } from './ui/button.js'; import SituationCard from './ui/SituationCard.js'; import PromptModal from './ui/PromptModal.js'; @@ -40,8 +40,9 @@ const careerSituations = [ ]; -function SignUp() { +function SignUp({ inviteData: inviteDataProp = null }) { const navigate = useNavigate(); + const [searchParams] = useSearchParams(); // existing states const [username, setUsername] = useState(''); @@ -56,19 +57,79 @@ function SignUp() { const [area, setArea] = useState(''); const [error, setError] = useState(''); const [loadingAreas, setLoadingAreas] = useState(false); - const [phone, setPhone] = useState('+1'); + const [phone, setPhone] = useState('+1'); const [optIn, setOptIn] = useState(false); const [areas, setAreas] = useState([]); const [areasErr, setAreasErr] = useState(''); const areasCacheRef = useRef(new Map()); // cache: stateCode -> areas[] const debounceRef = useRef(null); // debounce timer const inflightRef = useRef(null); // AbortController for in-flight - - + + // Date of birth for age verification + const [dobMonth, setDobMonth] = useState(''); + const [dobDay, setDobDay] = useState(''); + const [dobYear, setDobYear] = useState(''); + const [showCareerSituations, setShowCareerSituations] = useState(false); const [selectedSituation, setSelectedSituation] = useState(null); const [showPrompt, setShowPrompt] = useState(false); - + + // Invitation state + const [inviteToken, setInviteToken] = useState(null); + const [inviteValidating, setInviteValidating] = useState(false); + const [inviteValid, setInviteValid] = useState(false); + + // Check for invitation token in URL query params + useEffect(() => { + const token = searchParams.get('invite'); + if (!token) return; + + console.log('[SignUp] Found invite token in URL:', token); + setInviteValidating(true); + setInviteToken(token); + + // Validate the invitation token + fetch('/api/validate-invite', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }) + .then(res => res.json()) + .then(data => { + console.log('[SignUp] Invite validation response:', data); + if (data.valid) { + setInviteValid(true); + setEmail(data.email); + setConfirmEmail(data.email); + if (data.firstname) setFirstname(data.firstname); + if (data.lastname) setLastname(data.lastname); + setError(''); + } else { + setError(data.error || 'Invalid or expired invitation link'); + setInviteValid(false); + } + }) + .catch(err => { + console.error('[SignUp] Error validating invitation:', err); + setError('Failed to validate invitation. Please try again or contact your administrator.'); + setInviteValid(false); + }) + .finally(() => { + setInviteValidating(false); + }); + }, [searchParams]); + + // Legacy: Pre-fill email if invitation data is provided via prop (InviteSignup component - will be deprecated) + useEffect(() => { + console.log('[SignUp] inviteDataProp:', inviteDataProp); + if (inviteDataProp && inviteDataProp.email) { + setEmail(inviteDataProp.email); + setConfirmEmail(inviteDataProp.email); + setInviteToken(inviteDataProp.token); + setInviteValid(true); + } + }, [inviteDataProp]); + const states = [ { name: 'Alabama', code: 'AL' }, { name: 'Alaska', code: 'AK' }, { name: 'Arizona', code: 'AZ' }, @@ -94,18 +155,60 @@ function SignUp() { const validateFields = async () => { const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/; const zipRegex = /^\d{5}$/; - const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/; const usPhoneRegex = /^\+1\d{10}$/; if ( !username || !password || !confirmPassword || !firstname || !lastname || !email || !confirmEmail || + !dobMonth || !dobDay || !dobYear || !zipcode || !state || !area ) { setError('All fields are required.'); return false; } + + // Validate date of birth and calculate age + const month = parseInt(dobMonth, 10); + const day = parseInt(dobDay, 10); + const year = parseInt(dobYear, 10); + + if (month < 1 || month > 12) { + setError('Invalid birth month.'); + return false; + } + + if (day < 1 || day > 31) { + setError('Invalid birth day.'); + return false; + } + + const currentYear = new Date().getFullYear(); + if (year < 1900 || year > currentYear) { + setError('Invalid birth year.'); + return false; + } + + // Check if date is valid (e.g., no Feb 31) + const birthDate = new Date(year, month - 1, day); + if (birthDate.getMonth() !== month - 1 || birthDate.getDate() !== day) { + setError('Invalid date. Please check your birth date.'); + return false; + } + + // Calculate age + const today = new Date(); + let age = today.getFullYear() - year; + const monthDiff = today.getMonth() - (month - 1); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < day)) { + age--; + } + + // COPPA compliance: Must be at least 13 + if (age < 13) { + setError('You must be at least 13 years old to use AptivaAI. If you believe this is an error, please contact support.'); + return false; + } if (!emailRegex.test(email)) { setError('Enter a valid email address.'); @@ -171,6 +274,9 @@ const handleSituationConfirm = async () => { setShowPrompt(false); try { + // Format DOB as YYYY-MM-DD for backend + const dob = `${dobYear}-${dobMonth.padStart(2, '0')}-${dobDay.padStart(2, '0')}`; + const response = await fetch('/api/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -180,12 +286,14 @@ const handleSituationConfirm = async () => { firstname, lastname, email, + date_of_birth: dob, zipcode, state, area, phone_e164 : phone, sms_opt_in : optIn, - career_situation: selectedSituation.id + career_situation: selectedSituation.id, + inviteToken: inviteToken || inviteDataProp?.token || null // Include invite token if present }), }); @@ -253,7 +361,19 @@ return (
    {!showCareerSituations ? (
    -

    Sign Up

    +

    + {(inviteToken || inviteDataProp) ? 'Complete Your Invitation' : 'Sign Up'} +

    + {inviteValidating && ( +
    + Validating invitation... +
    + )} + {inviteValid && !error && ( +
    + You've been invited! Please complete your account setup below. +
    + )} {error && (
    {error} @@ -298,6 +418,9 @@ return ( placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} + readOnly={!!(inviteToken || inviteDataProp)} + disabled={!!(inviteToken || inviteDataProp)} + style={(inviteToken || inviteDataProp) ? { backgroundColor: '#f3f4f6', cursor: 'not-allowed' } : {}} /> setConfirmEmail(e.target.value)} + readOnly={!!(inviteToken || inviteDataProp)} + disabled={!!(inviteToken || inviteDataProp)} + style={(inviteToken || inviteDataProp) ? { backgroundColor: '#f3f4f6', cursor: 'not-allowed' } : {}} /> + {/* ─────────────── Date of Birth (Age Verification) ─────────────── */} +
    + +
    + setDobMonth(e.target.value)} + /> + setDobDay(e.target.value)} + /> + setDobYear(e.target.value)} + /> +
    +

    + 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. +

    +
    +
    +
    + {/* Password */}