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 = `
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.
+ ++ Already have an AptivaAI account with a different email? +
+ +This invitation link is valid for 7 days.
+Questions? Reply to this email or contact your administrator.
+— The AptivaAI Team
+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
+Option 2: Create a Separate Account
+This invitation link is valid for 7 days.
+Questions? Reply to this email or contact your administrator.
+— The AptivaAI Team
+${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' && (
+ Add a new student to your organization +
++ Sign in to manage your organization +
++ These email addresses are invalid or unreachable. Update the email and resend. +
+Loading bounced invitations...
++ All invitation emails were delivered successfully +
+| + 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'} + + | ++ + | +
+ {editingStudent.firstname} {editingStudent.lastname} +
+{editingStudent.email}
+Loading dashboard...
++ Overview of your organization's student engagement +
++ {career.career_name} +
++ No career exploration data available for the selected filters. +
+ )} +Regional Salary
++ {career.career_name} +
+{stats.orgState}
++ {career.career_name} +
+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 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 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 +
+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 +
++ {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
++ {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()} +
+{stats.orgState}
++ {career.career_name} +
++ These students have been invited but haven't created their accounts yet +
+Loading pending invitations...
++ All invited students have created their accounts +
+| + Name + | ++ Email + | ++ Invited + | ++ Actions + | +
|---|---|---|---|
|
+
+ {student.firstname} {student.lastname}
+
+ |
+
+ {student.email}
+ |
+ + {formatDate(student.invitation_sent_at || student.created_at)} + | ++ + | +
+ Upload student rosters and view upload history +
+email, firstname, lastname{orgType === 'K-12 School' && <>, grade_level>}grade_level (if provided, must be 9-12){error}
+{success}
+| First Name | +Last Name | +Grade | +Status | +|
|---|---|---|---|---|
| {row.email} | +{row.firstname} | +{row.lastname} | +{row.grade_level} | ++ {row.valid ? ( + Valid + ) : ( + Invalid + )} + | +
Loading history...
+No roster uploads yet
+| + 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}% + + | +
+ Manage your organization settings and configuration +
+{error}
+{success}
++ Configure how long after roster upload students in grades 11-12 should be prompted to complete Premium Onboarding. This delay gives students time to explore free features before creating career and college profiles. +
++ Juniors and seniors (grades 11-12) will be prompted to complete Premium Onboarding {orgProfile.onboarding_delay_days} days after being added to your roster. Freshmen and sophomores are never prompted. +
++ Configure your institution's academic calendar. This information helps AptivaAI align roster updates with your academic 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 */} +Roster uploads must occur after this deadline
+Roster uploads must occur after this deadline
+Roster uploads must occur after this deadline
+Roster uploads must occur after this deadline
++ Super Admin access is managed by AptivaAI. Contact support for additional Super Admins. +
+| Name | +Role | +Added | + {admin?.isSuperAdmin && ( +Actions | + )} +|
|---|---|---|---|---|
| + {user.firstname} {user.lastname} + | ++ {user.email} + | ++ + {user.role === 'super_admin' ? 'Super Admin' : 'Staff Admin'} + + | ++ {new Date(user.created_at).toLocaleDateString()} + | + {admin?.isSuperAdmin && ( ++ {user.user_id !== admin.userId && ( + + )} + | + )} +
Subscription Plan
+{billing.subscription_plan}
+Status
++ + {billing.subscription_status} + +
+Discount Eligible
++ + {billing.discount_eligible ? 'Yes' : 'No'} + +
++ For billing changes or questions, please contact your AptivaAI account representative. +
+No billing information available
+{student.email}
++ This will revoke the student's access immediately. Please select the reason: +
+{career.career_name}
+No careers explored yet
+ )} +Interest inventory not completed yet
+ )} ++ 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
+ )} ++ 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
+ )} ++ Note: Expected salary projections from College Profiles are visible above as they are considered career planning data, not personal financial information. +
+{milestone.title}
+ {milestone.description && ( +{milestone.description}
+ )} ++ Career: {milestone.career_name} | Target: {formatDate(milestone.date)} +
+No career roadmap milestones created yet
+ )} +What this student has chosen to share with your organization:
++ Manage your organization's student roster +
+Loading students...
+No students found
++ Showing {filteredStudents.length} student{filteredStudents.length !== 1 ? 's' : ''} +
+ +| + 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 + + | +
+ Showing {page * limit + 1} to{' '} + {Math.min((page + 1) * limit, filteredStudents.length)} of{' '} + {filteredStudents.length} students +
+Validating invitation...
+{error}
+ +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}
++ Questions? Contact your administrator for help. +
+Validating invitation...
+{error}
+ +Validating invitation...
+{error}
+ +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}
++ Questions? Contact your administrator for help. +
+- 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. +
+ ++ AptivaAI uses the following third-party service providers to deliver our services: +
++ We require all service providers to maintain appropriate security measures and + to use your information only for the purposes we specify.
+ 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{' '}
+
- 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{' '}
+ You are not enrolled in any organizations. Privacy settings are only available for students enrolled through organizations. +
++ Control what information you share with your educational organizations. Your privacy is important to us - all settings default to private. +
++ Choose what data you want to share with {org.organization_name}. You can change these settings at any time. +
+ ++ 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. +
+{description}
+{description}
++ 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. +
+{description}
+{description}
++ {RIASEC_DESCRIPTIONS[score.area]} +
++ You must be at least 13 years old to use AptivaAI. We encrypt and securely store your date of birth for legal compliance only. +
+- 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.
+ 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. +
+