/************************************************** * 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); } // Username lookup helper (HMAC-SHA256 hash for encrypted username querying) function usernameLookup(username) { const USERNAME_INDEX_KEY = process.env.USERNAME_INDEX_SECRET || JWT_SECRET; return crypto .createHmac('sha256', USERNAME_INDEX_KEY) .update(String(username).trim().toLowerCase()) .digest('hex'); } // SendGrid configuration (match server2.js exactly) const SENDGRID_KEY = (process.env.SUPPORT_SENDGRID_API_KEY || '') .trim() .replace(/^['"]+|['"]+$/g, ''); // strip leading/trailing quotes if GCP injects them if (SENDGRID_KEY) { sgMail.setApiKey(SENDGRID_KEY); console.log('[MAIL] SendGrid enabled'); } else { console.warn('[MAIL] SUPPORT_SENDGRID_API_KEY missing/empty; invitation emails will be logged only'); } const CANARY_SQL = ` CREATE TABLE IF NOT EXISTS encryption_canary ( id TINYINT NOT NULL PRIMARY KEY, value TEXT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;`; // ═══════════════════════════════════════════════════════════════════════════ // DEK BOOTSTRAP + CANARY VERIFICATION (BEFORE SERVING TRAFFIC) // ═══════════════════════════════════════════════════════════════════════════ try { await initEncryption(); const db = pool.raw || pool; // Quick connectivity check await db.query('SELECT 1'); // Ensure canary table exists await db.query(CANARY_SQL); // Insert sentinel on first run (ignore if exists) await db.query( 'INSERT IGNORE INTO encryption_canary (id, value) VALUES (1, ?)', [encrypt(SENTINEL)] ); // Read back & verify const [rows] = await db.query( 'SELECT value FROM encryption_canary WHERE id = 1 LIMIT 1' ); const plaintext = decrypt(rows[0]?.value || ''); if (plaintext !== SENTINEL) { throw new Error('DEK mismatch with database sentinel'); } console.log('[ENCRYPT] DEK verified against canary – proceeding'); } catch (err) { console.error('FATAL:', err?.message || err); process.exit(1); } // ═══════════════════════════════════════════════════════════════════════════ // LOAD ECONOMIC PROJECTIONS DATA // ═══════════════════════════════════════════════════════════════════════════ const DATA_DIR = path.join(__dirname, 'data'); const PROJ_FILE = path.join(DATA_DIR, 'economicproj.json'); let allProjections = []; try { const raw = await readFile(PROJ_FILE, 'utf8'); allProjections = JSON.parse(raw); console.log(`[DATA] Loaded ${allProjections.length} economic projection rows`); } catch (err) { console.warn('[DATA] Failed to load economicproj.json:', err.message); } // Helper to get full state name from abbreviation function fullStateFrom(s = '') { const M = { AL:'Alabama', AK:'Alaska', AZ:'Arizona', AR:'Arkansas', CA:'California', CO:'Colorado', CT:'Connecticut', DE:'Delaware', DC:'District of Columbia', FL:'Florida', GA:'Georgia', HI:'Hawaii', ID:'Idaho', IL:'Illinois', IN:'Indiana', IA:'Iowa', KS:'Kansas', KY:'Kentucky', LA:'Louisiana', ME:'Maine', MD:'Maryland', MA:'Massachusetts', MI:'Michigan', MN:'Minnesota', MS:'Mississippi', MO:'Missouri', MT:'Montana', NE:'Nebraska', NV:'Nevada', NH:'New Hampshire', NJ:'New Jersey', NM:'New Mexico', NY:'New York', NC:'North Carolina', ND:'North Dakota', OH:'Ohio', OK:'Oklahoma', OR:'Oregon', PA:'Pennsylvania', RI:'Rhode Island', SC:'South Carolina', SD:'South Dakota', TN:'Tennessee', TX:'Texas', UT:'Utah', VT:'Vermont', VA:'Virginia', WA:'Washington', WV:'West Virginia', WI:'Wisconsin', WY:'Wyoming' }; if (!s) return ''; const up = String(s).trim().toUpperCase(); return M[up] || s; // already full name → return as-is } // ═══════════════════════════════════════════════════════════════════════════ // EXPRESS APP & MIDDLEWARE // ═══════════════════════════════════════════════════════════════════════════ const app = express(); const PORT = process.env.SERVER4_PORT || 5003; const ADMIN_COOKIE_NAME = 'aptiva_admin_session'; app.disable('x-powered-by'); app.use(express.json({ limit: '10mb' })); app.set('trust proxy', 1); // behind proxy/HTTPS in all envs app.use(cookieParser()); app.use(helmet({ contentSecurityPolicy: false, crossOriginEmbedderPolicy: false })); app.use((req, res, next) => { if (req.path.startsWith('/api/')) res.type('application/json'); next(); }); // ═══════════════════════════════════════════════════════════════════════════ // REQUEST ID + MINIMAL AUDIT LOG FOR /api/* // ═══════════════════════════════════════════════════════════════════════════ function getRequestId(req, res) { const hdr = req.headers['x-request-id']; if (typeof hdr === 'string' && hdr) return hdr; // from Nginx const rid = crypto?.randomUUID?.() || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2,8)}`; res.setHeader('X-Request-ID', rid); return rid; } app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); const rid = getRequestId(req, res); const t0 = process.hrtime.bigint(); res.on('finish', () => { const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n); const out = { ts: new Date().toISOString(), rid, ip: req.ip || req.headers['x-forwarded-for'] || '', method: req.method, path: req.path, status: res.statusCode, dur_ms: durMs, bytes_sent: Number(res.getHeader('Content-Length') || 0), userId: req.userId || req.admin?.userId || null }; try { console.log(JSON.stringify(out)); } catch {} }); next(); }); // ═══════════════════════════════════════════════════════════════════════════ // DETAILED AUDIT LOGGING (REDACT SENSITIVE DATA) // ═══════════════════════════════════════════════════════════════════════════ function pickIp(req) { return req.ip || req.headers['x-forwarded-for'] || req.socket?.remoteAddress || ''; } function redactHeaders(h) { const out = { ...h }; delete out.authorization; delete out.cookie; delete out['x-forwarded-for']; return out; } function sampleBody(b) { if (!b || typeof b !== 'object') return undefined; const keys = Object.keys(b); const preview = {}; for (const k of keys.slice(0, 12)) { const v = b[k]; preview[k] = typeof v === 'string' ? (v.length > 80 ? v.slice(0, 80) + '…' : v) : (Array.isArray(v) ? `[array:${v.length}]` : typeof v); } return preview; } app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); const rid = req.headers['x-request-id'] || crypto.randomUUID?.() || String(Date.now()); res.setHeader('X-Request-ID', rid); const t0 = process.hrtime.bigint(); const reqLog = { ts: new Date().toISOString(), rid, ip: pickIp(req), method: req.method, path: req.path, userId: req.userId || req.admin?.userId || null, ua: req.headers['user-agent'] || '', hdr: redactHeaders(req.headers), body: sampleBody(req.body) }; res.on('finish', () => { const durMs = Number((process.hrtime.bigint() - t0) / 1_000_000n); const out = { ...reqLog, status: res.statusCode, dur_ms: durMs, bytes_sent: Number(res.getHeader('Content-Length') || 0) }; try { console.log(JSON.stringify(out)); } catch {} }); next(); }); // ═══════════════════════════════════════════════════════════════════════════ // RUNTIME: NEVER CACHE API RESPONSES // ═══════════════════════════════════════════════════════════════════════════ app.use((req, res, next) => { if (req.path.startsWith('/api/')) { res.set('Cache-Control', 'no-store'); res.set('Pragma', 'no-cache'); res.set('Expires', '0'); } next(); }); process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e)); process.on('uncaughtException', (e) => console.error('[uncaughtException]', e)); // ═══════════════════════════════════════════════════════════════════════════ // RUNTIME: ENFORCE JSON ON API WRITES (WITH NARROW EXCEPTIONS) // ═══════════════════════════════════════════════════════════════════════════ const MUST_JSON = new Set(['POST','PUT','PATCH']); const EXEMPT_PATHS = [ // Add any multipart/form-data endpoints here if needed ]; app.use((req, res, next) => { if (!req.path.startsWith('/api/')) return next(); if (!MUST_JSON.has(req.method)) return next(); if (EXEMPT_PATHS.some(rx => rx.test(req.path))) return next(); const ct = req.headers['content-type'] || ''; if (!ct.toLowerCase().includes('application/json')) { return res.status(415).json({ error: 'unsupported_media_type' }); } next(); }); // ═══════════════════════════════════════════════════════════════════════════ // RUNTIME PROTECTION: HPP GUARD (DEDUPE + CAP ARRAYS) // ═══════════════════════════════════════════════════════════════════════════ app.use((req, _res, next) => { const MAX_ARRAY = 20; const sanitize = (obj) => { if (!obj || typeof obj !== 'object') return; for (const k of Object.keys(obj)) { const v = obj[k]; if (Array.isArray(v)) { obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null); if (obj[k].length === 1) obj[k] = obj[k][0]; } } }; sanitize(req.query); sanitize(req.body); next(); }); // ═══════════════════════════════════════════════════════════════════════════ // RUNTIME: REJECT REQUEST BODIES ON GET/HEAD // ═══════════════════════════════════════════════════════════════════════════ app.use((req, res, next) => { if ((req.method === 'GET' || req.method === 'HEAD') && Number(req.headers['content-length'] || 0) > 0) { return res.status(400).json({ error: 'no_body_allowed' }); } next(); }); // ═══════════════════════════════════════════════════════════════════════════ // RUNTIME: LAST-RESORT ERROR SANITIZER // ═══════════════════════════════════════════════════════════════════════════ app.use((err, req, res, _next) => { if (res.headersSent) return; if (err?.code === 'LIMIT_FILE_SIZE') { return res.status(413).json({ error: 'file_too_large', limit_mb: 10 }); } if (err?.message && String(err.message).startsWith('blocked_outbound_host:')) { return res.status(400).json({ error: 'blocked_outbound_host' }); } if (err?.message === 'unsupported_type') { return res.status(415).json({ error: 'unsupported_type' }); } console.error('[unhandled]', err?.message || err); return res.status(500).json({ error: 'Server error' }); }); // ═══════════════════════════════════════════════════════════════════════════ // CORS (STRICT ALLOWLIST FROM ENV) // ═══════════════════════════════════════════════════════════════════════════ const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS .split(',') .map(o => o.trim()) .filter(Boolean); app.use((req, res, next) => { const origin = req.headers.origin || ''; if (!origin) return next(); if (!allowedOrigins.includes(origin)) { return res.status(403).end(); } res.setHeader('Access-Control-Allow-Origin', origin); res.setHeader('Access-Control-Allow-Credentials', 'true'); res.setHeader( 'Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Origin, X-Requested-With, Access-Control-Allow-Methods' ); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); if (req.method === 'OPTIONS') return res.status(204).end(); return next(); }); // ═══════════════════════════════════════════════════════════════════════════ // SESSION COOKIE OPTIONS // ═══════════════════════════════════════════════════════════════════════════ function sessionCookieOptions() { const IS_HTTPS = true; const CROSS_SITE = process.env.CROSS_SITE_COOKIES === '1'; const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined; return { httpOnly: true, secure: IS_HTTPS, sameSite: CROSS_SITE ? 'none' : 'lax', path: '/', maxAge: 8 * 60 * 60 * 1000, // 8 hours for admin sessions ...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}), }; } function fprPathFromEnv() { const p = (process.env.DEK_PATH || '').trim(); return p ? path.join(path.dirname(p), 'dek.fpr') : null; } // ═══════════════════════════════════════════════════════════════════════════ // HEALTH CHECK ENDPOINTS // ═══════════════════════════════════════════════════════════════════════════ // 1) Liveness: process is up and event loop responsive app.get('/livez', (_req, res) => res.type('text').send('OK')); // 2) Readiness: crypto + canary are good app.get('/readyz', async (_req, res) => { try { await initEncryption(); await verifyCanary(pool); return res.type('text').send('OK'); } catch (e) { console.error('[READYZ]', e.message); return res.status(500).type('text').send('FAIL'); } }); // 3) Health: detailed JSON app.get('/healthz', async (_req, res) => { const out = { service: 'server4-admin-api', version: process.env.IMG_TAG || null, uptime_s: Math.floor(process.uptime()), now: new Date().toISOString(), checks: { live: { ok: true }, crypto: { ok: false, fp: null }, db: { ok: false, ping_ms: null }, canary: { ok: false } } }; // crypto / DEK try { await initEncryption(); out.checks.crypto.ok = true; const p = fprPathFromEnv(); if (p) { try { out.checks.crypto.fp = (await readFile(p, 'utf8')).trim(); } catch { /* fp optional */ } } } catch (e) { out.checks.crypto.error = e.message; } // DB ping const t0 = Date.now(); try { await pool.query('SELECT 1'); out.checks.db.ok = true; out.checks.db.ping_ms = Date.now() - t0; } catch (e) { out.checks.db.error = e.message; } // canary try { await verifyCanary(pool); out.checks.canary.ok = true; } catch (e) { out.checks.canary.error = e.message; } const ready = out.checks.crypto.ok && out.checks.db.ok && out.checks.canary.ok; return res.status(ready ? 200 : 503).json(out); }); // ═══════════════════════════════════════════════════════════════════════════ // RATE LIMITERS // ═══════════════════════════════════════════════════════════════════════════ const adminLoginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip }); const bulkUploadLimiter = rateLimit({ windowMs: 60 * 1000, max: 5, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip }); const rosterUploadLimiter = rateLimit({ windowMs: 60 * 1000, max: 3, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip }); const exportLimiter = rateLimit({ windowMs: 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip }); const resendLimiter = rateLimit({ windowMs: 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false, keyGenerator: (req) => req.ip }); // ═══════════════════════════════════════════════════════════════════════════ // EMAIL INVITATION HELPER // ═══════════════════════════════════════════════════════════════════════════ // Send invitation to NEW users (no existing AptivaAI account) async function sendNewUserInvitation(email, firstname, organizationName, organizationId, userId) { const inviteToken = jwt.sign( { email, userId, organizationId, prp: 'student_invite', isNewUser: true }, JWT_SECRET, { expiresIn: '7d' } ); const MAIN_APP_URL = process.env.APTIVA_API_BASE || 'https://aptivaai.com'; const inviteLink = `${MAIN_APP_URL}/signup?invite=${inviteToken}`; const linkAccountLink = `${MAIN_APP_URL}/link-secondary-email?token=${inviteToken}`; const text = `Hi ${firstname}, You've been invited to join ${organizationName} on AptivaAI! AptivaAI is a career exploration platform that helps you discover potential career paths, explore educational programs, and plan your professional future. Click the link below to create your account (valid for 7 days): ${inviteLink} Already have an AptivaAI account with a different email? Link it here: ${linkAccountLink} Questions? Reply to this email or contact your administrator. — The AptivaAI Team`; const html = `

Welcome to AptivaAI!

Hi ${firstname},

You've been invited to join ${organizationName} on AptivaAI!

AptivaAI is a career exploration platform that helps you discover potential career paths, explore educational programs, and plan your professional future.

Create Your Account

Already have an AptivaAI account with a different email?

Click here to link your existing account

This invitation link is valid for 7 days.

Questions? Reply to this email or contact your administrator.


— The AptivaAI Team

`; if (SENDGRID_KEY) { await sgMail.send({ to: email, from: 'no-reply@aptivaai.com', subject: `You're invited to ${organizationName} on AptivaAI`, text, html }); console.log(`[INVITE] Sent new user invitation email to ${email}`); } else { console.log(`[INVITE] SendGrid disabled. Would send new user invite to ${email}:`); console.log(`[INVITE] Link: ${inviteLink}`); } } // Send invitation to EXISTING users (already have AptivaAI account) async function sendExistingUserInvitation(email, firstname, organizationName, organizationId, userId) { const inviteToken = jwt.sign( { email, userId, organizationId, prp: 'student_invite', isNewUser: false }, JWT_SECRET, { expiresIn: '7d' } ); const MAIN_APP_URL = process.env.APTIVA_API_BASE || 'https://aptivaai.com'; const inviteLink = `${MAIN_APP_URL}/invite-response?token=${inviteToken}`; const text = `Hi ${firstname}, You've been invited to join ${organizationName} on AptivaAI! We noticed you already have an AptivaAI account. You have two options: 1. Link your existing account to ${organizationName} - Your existing data and progress will be preserved - ${organizationName} can view your activity based on your privacy settings - You'll gain premium access through ${organizationName} 2. Create a separate account for ${organizationName} - Keep your personal AptivaAI account separate - Start fresh with a new profile for school/organization use Click the link below to choose (valid for 7 days): ${inviteLink} Questions? Reply to this email or contact your administrator. — The AptivaAI Team`; const html = `

You're Invited to Join ${organizationName}!

Hi ${firstname},

You've been invited to join ${organizationName} on AptivaAI!

We noticed you already have an AptivaAI account

You have two options:

Option 1: Link Your Existing Account

Option 2: Create a Separate Account

Choose Your Option

This invitation link is valid for 7 days.

Questions? Reply to this email or contact your administrator.


— The AptivaAI Team

`; if (SENDGRID_KEY) { await sgMail.send({ to: email, from: 'no-reply@aptivaai.com', subject: `You're invited to ${organizationName} on AptivaAI`, text, html }); console.log(`[INVITE] Sent existing user invitation email to ${email}`); } else { console.log(`[INVITE] SendGrid disabled. Would send existing user invite to ${email}:`); console.log(`[INVITE] Link: ${inviteLink}`); } } // Legacy function - redirects to appropriate function based on isNewUser flag async function sendStudentInvitation(email, firstname, organizationName, organizationId, userId, isNewUser = true) { if (isNewUser) { return sendNewUserInvitation(email, firstname, organizationName, organizationId, userId); } else { return sendExistingUserInvitation(email, firstname, organizationName, organizationId, userId); } } // ═══════════════════════════════════════════════════════════════════════════ // VALIDATE INVITATION TOKEN (PUBLIC ENDPOINT) // ═══════════════════════════════════════════════════════════════════════════ app.post('/api/validate-invite', async (req, res) => { const { token } = req.body; if (!token) { return res.status(400).json({ error: 'Invitation token required' }); } try { const decoded = jwt.verify(token, JWT_SECRET); if (decoded.prp !== 'student_invite') { return res.status(400).json({ error: 'Invalid invitation token' }); } // Verify user still exists and is pending const [users] = await pool.execute( 'SELECT id, email, firstname, username FROM user_profile WHERE id = ? LIMIT 1', [decoded.userId] ); if (!users.length) { return res.status(404).json({ error: 'Invitation not found or expired' }); } const user = users[0]; // Check if already completed signup (username is not NULL) if (user.username) { return res.status(400).json({ error: 'This invitation has already been used. Please sign in instead.' }); } // Verify enrollment exists and is pending const [enrollment] = await pool.execute( 'SELECT enrollment_status FROM organization_students WHERE organization_id = ? AND user_id = ?', [decoded.organizationId, decoded.userId] ); if (!enrollment.length) { return res.status(404).json({ error: 'Invitation not found' }); } // Decrypt email for pre-fill let email = user.email; try { email = decrypt(email); } catch (err) { // Not encrypted or decryption failed } return res.json({ valid: true, email: email, userId: decoded.userId, organizationId: decoded.organizationId }); } catch (err) { if (err.name === 'TokenExpiredError') { return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' }); } console.error('[validate-invite] Error:', err.message); return res.status(400).json({ error: 'Invalid invitation token' }); } }); // ═══════════════════════════════════════════════════════════════════════════ // AUTH MIDDLEWARE // ═══════════════════════════════════════════════════════════════════════════ async function requireAdminAuth(req, res, next) { const token = req.cookies[ADMIN_COOKIE_NAME]; if (!token) { return res.status(401).json({ error: 'Admin authentication required' }); } try { const decoded = jwt.verify(token, JWT_SECRET); const [admins] = await pool.execute(` SELECT oa.id, oa.organization_id, oa.user_id, oa.role, o.organization_name, o.organization_type FROM organization_admins oa JOIN organizations o ON oa.organization_id = o.id WHERE oa.user_id = ? LIMIT 1 `, [decoded.userId]); if (!admins || admins.length === 0) { return res.status(403).json({ error: 'Admin access not found' }); } const admin = admins[0]; req.admin = { userId: admin.user_id, organizationId: admin.organization_id, organizationName: admin.organization_name, organizationType: admin.organization_type, role: admin.role, isSuperAdmin: admin.role === 'super_admin' }; next(); } catch (err) { console.error('[requireAdminAuth] Error:', err.message); return res.status(401).json({ error: 'Invalid or expired admin session' }); } } function requireSuperAdmin(req, res, next) { if (!req.admin || !req.admin.isSuperAdmin) { return res.status(403).json({ error: 'Super admin access required' }); } next(); } // ═══════════════════════════════════════════════════════════════════════════ // AUTHENTICATION ENDPOINTS // ═══════════════════════════════════════════════════════════════════════════ app.post('/api/admin/auth/login', adminLoginLimiter, async (req, res) => { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ error: 'Username and password required' }); } try { // Use username_lookup hash for querying (username is encrypted) const usernameLookupVal = usernameLookup(username); const [authResults] = await pool.execute(` SELECT ua.user_id, ua.hashed_password FROM user_auth ua WHERE ua.username_lookup = ? LIMIT 1 `, [usernameLookupVal]); if (!authResults || authResults.length === 0) { return res.status(401).json({ error: 'Invalid credentials' }); } const { user_id, hashed_password } = authResults[0]; const isMatch = await bcrypt.compare(password, hashed_password); if (!isMatch) { return res.status(401).json({ error: 'Invalid credentials' }); } const [adminResults] = await pool.execute(` SELECT oa.id, oa.organization_id, oa.role, o.organization_name, o.organization_type FROM organization_admins oa JOIN organizations o ON oa.organization_id = o.id WHERE oa.user_id = ? LIMIT 1 `, [user_id]); if (!adminResults || adminResults.length === 0) { return res.status(403).json({ error: 'Not authorized as organization admin' }); } const admin = adminResults[0]; const token = jwt.sign( { userId: user_id, organizationId: admin.organization_id }, JWT_SECRET, { expiresIn: '8h' } ); res.cookie(ADMIN_COOKIE_NAME, token, sessionCookieOptions()); return res.json({ message: 'Login successful', admin: { organizationName: admin.organization_name, organizationType: admin.organization_type, role: admin.role } }); } catch (err) { console.error('[admin-login] Error:', err.message); return res.status(500).json({ error: 'Login failed' }); } }); app.post('/api/admin/auth/logout', (req, res) => { res.clearCookie(ADMIN_COOKIE_NAME, sessionCookieOptions()); return res.json({ message: 'Logged out successfully' }); }); app.get('/api/admin/auth/me', requireAdminAuth, (req, res) => { return res.json({ organizationId: req.admin.organizationId, organizationName: req.admin.organizationName, organizationType: req.admin.organizationType, role: req.admin.role, isSuperAdmin: req.admin.isSuperAdmin }); }); // ═══════════════════════════════════════════════════════════════════════════ // DASHBOARD ENDPOINTS // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/admin/dashboard/stats', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const [totalResult] = await pool.execute(` SELECT COUNT(*) as count FROM organization_students WHERE organization_id = ? AND enrollment_status NOT IN ('withdrawn', 'transferred', 'inactive') `, [orgId]); const [activeResult] = await pool.execute(` SELECT COUNT(*) as count FROM organization_students os JOIN user_profile up ON os.user_id = up.id WHERE os.organization_id = ? AND os.enrollment_status = 'active' AND up.last_login >= DATE_SUB(NOW(), INTERVAL 30 DAY) `, [orgId]); const [rosterHistory] = await pool.execute(` SELECT submitted_at, students_added, total_roster_size FROM roster_updates WHERE organization_id = ? ORDER BY submitted_at DESC LIMIT 3 `, [orgId]); const [adminCount] = await pool.execute(` SELECT COUNT(*) as count FROM organization_admins WHERE organization_id = ? `, [orgId]); // Get organization's state and metro area for BLS data queries let orgState = 'US'; let orgStateAbbr = 'US'; let orgMetro = null; let topPayingCareers = []; let fastestGrowingCareers = []; let mostJobsCareers = []; try { const [orgData] = await pool.execute(` SELECT state, metro_area FROM organizations WHERE id = ? LIMIT 1 `, [orgId]); orgStateAbbr = orgData[0]?.state || 'US'; orgState = fullStateFrom(orgStateAbbr); orgMetro = orgData[0]?.metro_area; // Get highest paying careers - use projection data to get career list, then fetch salaries const salaryLocation = orgMetro || orgState; try { const stateName = orgState.toLowerCase(); // Get all careers for this state from projections data const stateCareers = allProjections .filter(r => { const area = String(r['Area Name'] || '').trim().toLowerCase(); return area === stateName; }) .map(r => ({ soc_code: r['Occupation Code'], career_name: r['Occupation Name'] })); // Deduplicate by SOC code (some may appear multiple times) const uniqueCareers = Array.from(new Map(stateCareers.map(c => [c.soc_code, c])).values()); // Sample top 50 careers to check salaries (to avoid too many API calls) const careersToCheck = uniqueCareers.slice(0, 50); // For each career, fetch salary data from server2 const salaryPromises = careersToCheck.map(async (career) => { try { const resp = await axios.get('http://server2:5001/api/salary', { params: { socCode: career.soc_code, area: salaryLocation }, timeout: 2000 }); const median = resp.data?.regional?.regional_MEDIAN || resp.data?.national?.national_MEDIAN; if (median) { return { career_name: career.career_name, median_salary: median, soc_code: career.soc_code }; } } catch (err) { // Skip careers with no salary data } return null; }); const salaryResults = (await Promise.all(salaryPromises)) .filter(x => x !== null) .sort((a, b) => b.median_salary - a.median_salary) .slice(0, 5); topPayingCareers = salaryResults; } catch (salaryErr) { console.error('[dashboard-stats] BLS salary query failed:', salaryErr.message); } // Get fastest growing careers by state (by growth rate %) try { const stateName = orgState.toLowerCase(); const stateProjections = allProjections.filter(r => { const area = String(r['Area Name'] || '').trim().toLowerCase(); return area === stateName; }); // Sort by percent change descending const sorted = stateProjections .map(r => ({ career_name: r['Occupation Name'], growth_rate: parseFloat(r['Percent Change']) || 0, soc_code: r['Occupation Code'] })) .sort((a, b) => b.growth_rate - a.growth_rate) .slice(0, 5); fastestGrowingCareers = sorted; } catch (growthErr) { console.error('[dashboard-stats] BLS projections query failed:', growthErr.message); } // Get careers with most projected jobs (total job count in 10 years) try { const stateName = orgState.toLowerCase(); const stateProjections = allProjections.filter(r => { const area = String(r['Area Name'] || '').trim().toLowerCase(); return area === stateName; }); // Sort by projection (total jobs in 10 years) descending const sorted = stateProjections .map(r => ({ career_name: r['Occupation Name'], projected_jobs: parseInt(r['Projection']) || 0, soc_code: r['Occupation Code'] })) .filter(x => x.projected_jobs > 0) .sort((a, b) => b.projected_jobs - a.projected_jobs) .slice(0, 5); mostJobsCareers = sorted; } catch (jobsErr) { console.error('[dashboard-stats] BLS jobs projections query failed:', jobsErr.message); } } catch (orgErr) { console.error('[dashboard-stats] Organization query failed:', orgErr.message); } // Get roster update reminders based on academic calendar let rosterReminders = []; try { const [calendarData] = await pool.execute(` SELECT calendar_type, fall_term_start_month, fall_term_start_day, fall_add_drop_deadline_days, winter_term_start_month, winter_term_start_day, winter_add_drop_deadline_days, spring_term_start_month, spring_term_start_day, spring_add_drop_deadline_days, summer_term_start_month, summer_term_start_day, summer_add_drop_deadline_days FROM academic_calendars WHERE organization_id = ? LIMIT 1 `, [orgId]); if (calendarData && calendarData.length > 0) { const calendar = calendarData[0]; const today = new Date(); const currentYear = today.getFullYear(); // Helper to calculate deadline date const getDeadlineDate = (month, day, offsetDays, year = currentYear) => { const termStart = new Date(year, month - 1, day); const deadline = new Date(termStart); deadline.setDate(deadline.getDate() + offsetDays); return deadline; }; // Check each term based on calendar type const terms = []; // Fall term (all types) if (calendar.fall_term_start_month) { const fallDeadline = getDeadlineDate(calendar.fall_term_start_month, calendar.fall_term_start_day, calendar.fall_add_drop_deadline_days || 14); terms.push({ name: 'Fall', deadline: fallDeadline }); } // Winter term (quarter/trimester) if (calendar.winter_term_start_month && ['quarter', 'trimester'].includes(calendar.calendar_type)) { const winterDeadline = getDeadlineDate(calendar.winter_term_start_month, calendar.winter_term_start_day, calendar.winter_add_drop_deadline_days || 14); terms.push({ name: 'Winter', deadline: winterDeadline }); } // Spring term (all types) if (calendar.spring_term_start_month) { const springDeadline = getDeadlineDate(calendar.spring_term_start_month, calendar.spring_term_start_day, calendar.spring_add_drop_deadline_days || 14); terms.push({ name: 'Spring', deadline: springDeadline }); } // Summer term (quarter only) if (calendar.summer_term_start_month && calendar.calendar_type === 'quarter') { const summerDeadline = getDeadlineDate(calendar.summer_term_start_month, calendar.summer_term_start_day, calendar.summer_add_drop_deadline_days || 7); terms.push({ name: 'Summer', deadline: summerDeadline }); } // Find upcoming deadlines for (const term of terms) { const daysUntilDeadline = Math.ceil((term.deadline - today) / (1000 * 60 * 60 * 24)); if (daysUntilDeadline > 0 && daysUntilDeadline <= 30) { rosterReminders.push({ term: term.name, deadline: term.deadline.toISOString().split('T')[0], daysUntil: daysUntilDeadline, status: 'due_soon' }); } else if (daysUntilDeadline < 0 && daysUntilDeadline >= -7) { rosterReminders.push({ term: term.name, deadline: term.deadline.toISOString().split('T')[0], daysUntil: Math.abs(daysUntilDeadline), status: 'overdue' }); } } } } catch (reminderErr) { console.error('[dashboard-stats] Roster reminders query failed:', reminderErr.message); } // Career Outcome Linkage Metrics let careerOutcomeMetrics = { studentsWithCareerProfiles: 0, careerProfilesMatchingExploration: 0, careerProfilesMatchingCareerComparison: 0, studentsWithCollegeProfiles: 0 }; try { // Count students with career profiles const [careerProfileCount] = await pool.execute(` SELECT COUNT(DISTINCT cp.user_id) as count FROM career_profiles cp JOIN organization_students os ON cp.user_id = os.user_id WHERE os.organization_id = ? AND os.enrollment_status = 'active' `, [orgId]); careerOutcomeMetrics.studentsWithCareerProfiles = careerProfileCount[0].count; // Count career profiles that match careers students explored (career_views) // Match on SOC code for accuracy (career names may vary) const [explorationMatch] = await pool.execute(` SELECT COUNT(DISTINCT cp.user_id) as count FROM career_profiles cp JOIN organization_students os ON cp.user_id = os.user_id JOIN career_views cv ON cp.user_id = cv.user_id AND cp.career_soc_code COLLATE utf8mb4_unicode_ci = cv.career_soc_code COLLATE utf8mb4_unicode_ci WHERE os.organization_id = ? AND os.enrollment_status = 'active' AND cp.career_soc_code IS NOT NULL `, [orgId]); careerOutcomeMetrics.careerProfilesMatchingExploration = explorationMatch[0].count; // Count career profiles that match careers in Career Comparison (career_list) // Note: career_list is encrypted, must decrypt before parsing const [studentsWithProfiles] = await pool.execute(` SELECT DISTINCT cp.user_id, cp.career_soc_code, up.career_list FROM career_profiles cp JOIN organization_students os ON cp.user_id = os.user_id JOIN user_profile up ON cp.user_id = up.id WHERE os.organization_id = ? AND os.enrollment_status = 'active' AND up.career_list IS NOT NULL AND cp.career_soc_code IS NOT NULL `, [orgId]); let comparisonMatchCount = 0; for (const row of studentsWithProfiles) { try { const decryptedList = decrypt(row.career_list); const careerList = JSON.parse(decryptedList); if (Array.isArray(careerList) && careerList.some(c => c.code === row.career_soc_code)) { comparisonMatchCount++; } } catch (err) { // Skip if decrypt/parse fails } } careerOutcomeMetrics.careerProfilesMatchingCareerComparison = comparisonMatchCount; // Count students with college profiles const [collegeProfileCount] = await pool.execute(` SELECT COUNT(DISTINCT cp.user_id) as count FROM college_profiles cp JOIN organization_students os ON cp.user_id = os.user_id WHERE os.organization_id = ? AND os.enrollment_status = 'active' `, [orgId]); careerOutcomeMetrics.studentsWithCollegeProfiles = collegeProfileCount[0].count; // Note: Program alignment requires CIP-to-SOC mapping which is in server2 // For now, just track college profile creation } catch (outcomeErr) { console.error('[dashboard-stats] Career outcome metrics failed:', outcomeErr.message); } return res.json({ totalStudents: totalResult[0].count, activeStudents: activeResult[0].count, recentUploads: rosterHistory, adminCount: adminCount[0].count, topPayingCareers: topPayingCareers, fastestGrowingCareers: fastestGrowingCareers, mostJobsCareers: mostJobsCareers, orgState: orgState, orgMetro: orgMetro, rosterReminders: rosterReminders, careerOutcomes: careerOutcomeMetrics }); } catch (err) { console.error('[dashboard-stats] Error:', err.message); return res.status(500).json({ error: 'Failed to load dashboard stats' }); } }); // Student Career Interests with filters app.get('/api/admin/dashboard/career-interests', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const { signalStrength = 'viewed', timePeriod = '90', limit = '10' } = req.query; const limitNum = Math.min(parseInt(limit) || 10, 100); // Max 100, default 10 // Map time period to days const dayMap = { '30': 30, '90': 90, '365': 365, 'all': 9999 }; const days = dayMap[timePeriod] || 90; let query, params; // Build query based on signal strength switch (signalStrength) { case 'viewed': query = ` SELECT cv.career_name, cv.career_soc_code, COUNT(DISTINCT cv.user_id) as student_count FROM career_views cv JOIN organization_students os ON cv.user_id = os.user_id WHERE os.organization_id = ? AND os.enrollment_status = 'active' ${days < 9999 ? 'AND cv.viewed_at >= DATE_SUB(NOW(), INTERVAL ? DAY)' : ''} GROUP BY cv.career_soc_code, cv.career_name ORDER BY student_count DESC LIMIT ? `; params = days < 9999 ? [orgId, days, limitNum] : [orgId, limitNum]; break; case 'compared': // career_list is encrypted, must fetch and parse in app code query = ` SELECT up.id as user_id, up.career_list, up.updated_at FROM user_profile up JOIN organization_students os ON up.id = os.user_id WHERE os.organization_id = ? AND os.enrollment_status = 'active' AND up.career_list IS NOT NULL `; params = [orgId]; const [rows] = await pool.execute(query, params); const careerCounts = {}; for (const row of rows) { try { const decrypted = decrypt(row.career_list); const careerList = JSON.parse(decrypted); if (Array.isArray(careerList)) { for (const career of careerList) { const key = career.code || career.title; if (!careerCounts[key]) { careerCounts[key] = { career_name: career.title, career_soc_code: career.code, student_count: 0 }; } careerCounts[key].student_count++; } } } catch (err) { // Skip if decrypt/parse fails } } const results = Object.values(careerCounts) .sort((a, b) => b.student_count - a.student_count) .slice(0, limitNum); return res.json({ careers: results }); case 'profiled': query = ` SELECT cp.career_name, cp.career_soc_code, COUNT(DISTINCT cp.user_id) as student_count FROM career_profiles cp JOIN organization_students os ON cp.user_id = os.user_id WHERE os.organization_id = ? AND os.enrollment_status = 'active' ${days < 9999 ? 'AND cp.created_at >= DATE_SUB(NOW(), INTERVAL ? DAY)' : ''} GROUP BY cp.career_soc_code, cp.career_name ORDER BY student_count DESC LIMIT ? `; params = days < 9999 ? [orgId, days, limitNum] : [orgId, limitNum]; break; default: return res.status(400).json({ error: 'Invalid signal strength' }); } const [results] = await pool.execute(query, params); return res.json({ careers: results }); } catch (err) { console.error('[career-interests] Error:', err.message); return res.status(500).json({ error: 'Failed to load career interests' }); } }); // ═══════════════════════════════════════════════════════════════════════════ // STUDENT MANAGEMENT ENDPOINTS // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/admin/students', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const { status } = req.query; // Return all students - filtering/search happens on frontend after decryption let query = ` SELECT os.id, os.user_id, os.enrollment_status, os.enrollment_date, os.invitation_sent_at, os.status_changed_date, os.bounce_reason, os.updated_at, up.firstname, up.lastname, up.email, up.last_login, up.created_at FROM organization_students os JOIN user_profile up ON os.user_id = up.id WHERE os.organization_id = ? `; const params = [orgId]; if (status) { query += ` AND os.enrollment_status = ?`; params.push(status); } query += ` ORDER BY os.enrollment_date DESC`; const [students] = await pool.execute(query, params); // Add engagement metrics for each student (with guards for missing tables) for (const student of students) { student.inventory_completed_at = null; student.career_profiles_count = 0; student.college_profiles_count = 0; student.financial_profiles_count = 0; student.roadmaps_count = 0; try { const [careerProfiles] = await pool.execute('SELECT COUNT(*) as count FROM career_profiles WHERE user_id = ?', [student.user_id]); student.career_profiles_count = careerProfiles[0].count; } catch (err) { // Table doesn't exist } try { const [collegeProfiles] = await pool.execute(` SELECT COUNT(*) as count FROM college_profiles cp_col JOIN career_profiles cp ON cp_col.career_profile_id = cp.id WHERE cp.user_id = ? `, [student.user_id]); student.college_profiles_count = collegeProfiles[0].count; } catch (err) { // Table doesn't exist or no join possible } try { const [financialProfiles] = await pool.execute('SELECT COUNT(*) as count FROM financial_profiles WHERE user_id = ?', [student.user_id]); student.financial_profiles_count = financialProfiles[0].count; } catch (err) { // Table doesn't exist } try { const [milestones] = await pool.execute(` SELECT COUNT(*) as count FROM milestones m JOIN career_profiles cp ON m.career_profile_id = cp.id WHERE cp.user_id = ? `, [student.user_id]); student.roadmaps_count = milestones[0].count; } catch (err) { // Table doesn't exist or no join possible } } // Decrypt all encrypted fields for (const student of students) { if (student.firstname) { try { student.firstname = decrypt(student.firstname); } catch { // If not encrypted or decryption fails, leave as-is } } if (student.lastname) { try { student.lastname = decrypt(student.lastname); } catch { // If not encrypted or decryption fails, leave as-is } } if (student.email) { try { student.email = decrypt(student.email); } catch { // If not encrypted or decryption fails, leave as-is } } // SECURITY: Remove user_id before sending to frontend delete student.user_id; } return res.json({ students }); } catch (err) { console.error('[students-list] Error:', err.message); return res.status(500).json({ error: 'Failed to load students' }); } }); app.get('/api/admin/students/:studentId', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const { studentId } = req.params; // studentId is now organization_students.id, not user_id const [studentResults] = await pool.execute(` SELECT os.*, up.firstname, up.lastname, up.email, up.last_login, up.created_at, up.career_list, up.interest_inventory_answers, up.riasec_scores FROM organization_students os JOIN user_profile up ON os.user_id = up.id WHERE os.organization_id = ? AND os.id = ? LIMIT 1 `, [orgId, studentId]); if (!studentResults || studentResults.length === 0) { return res.status(404).json({ error: 'Student not found' }); } const student = studentResults[0]; const userId = student.user_id; // Extract for internal queries console.log('[student-detail] Raw data from DB:', { userId, career_list: student.career_list, interest_inventory_answers: student.interest_inventory_answers }); // Decrypt email if (student.email) { try { student.email = decrypt(student.email); } catch { // Leave as-is if not encrypted } } // Check if interest inventory is completed (60-char string like "111222333...") student.inventory_completed_at = null; if (student.interest_inventory_answers && student.interest_inventory_answers.length === 60) { student.inventory_completed_at = student.created_at; } // Check if careers have been added to comparison list (career_list is JSON array in user_profile) student.career_comparison_count = 0; if (student.career_list) { try { const careerList = typeof student.career_list === 'string' ? JSON.parse(student.career_list) : student.career_list; student.career_comparison_count = Array.isArray(careerList) ? careerList.length : 0; } catch { // Invalid JSON or not an array } } // Count career profiles, college profiles, financial profiles, and roadmaps (premium features) student.career_profiles_count = 0; student.college_profiles_count = 0; student.financial_profiles_count = 0; student.roadmaps_count = 0; try { const [careerProfiles] = await pool.execute('SELECT COUNT(*) as count FROM career_profiles WHERE user_id = ?', [userId]); student.career_profiles_count = careerProfiles[0].count; } catch (err) { // Table doesn't exist } try { const [collegeProfiles] = await pool.execute('SELECT COUNT(*) as count FROM college_profiles WHERE user_id = ?', [userId]); student.college_profiles_count = collegeProfiles[0].count; } catch (err) { // Table doesn't exist } try { const [financialProfiles] = await pool.execute('SELECT COUNT(*) as count FROM financial_profiles WHERE user_id = ?', [userId]); student.financial_profiles_count = financialProfiles[0].count; } catch (err) { // Table doesn't exist } try { const [milestones] = await pool.execute(` SELECT COUNT(*) as count FROM milestones m JOIN career_profiles cp ON m.career_profile_id = cp.id WHERE cp.user_id = ? `, [userId]); student.roadmaps_count = milestones[0].count; } catch (err) { // Table doesn't exist or no join possible } // Remove sensitive fields before continuing delete student.career_list; delete student.interest_inventory_answers; const [privacyResults] = await pool.execute(` SELECT * FROM student_privacy_settings WHERE user_id = ? AND organization_id = ? LIMIT 1 `, [userId, orgId]); // Default to all false (private by default) if no settings exist const privacy = privacyResults[0] || { share_career_exploration: false, share_interest_inventory: false, share_career_profiles: false, share_college_profiles: false, share_financial_profile: false, share_roadmap: false }; let careers = []; if (privacy.share_career_exploration) { const [careerResults] = await pool.execute(` SELECT career_name, career_soc_code, COUNT(*) as view_count, MAX(viewed_at) as last_viewed FROM career_views WHERE user_id = ? GROUP BY career_soc_code, career_name ORDER BY last_viewed DESC LIMIT 10 `, [userId]); careers = careerResults; } // Fetch RIASEC scores if privacy allows let riasecScores = null; console.log('[student-detail] RIASEC check:', { share_interest_inventory: privacy.share_interest_inventory, has_riasec_scores: !!student.riasec_scores, riasec_scores_value: student.riasec_scores }); if (privacy.share_interest_inventory && student.riasec_scores) { try { const decrypted = decrypt(student.riasec_scores); const parsed = JSON.parse(decrypted); console.log('[student-detail] RIASEC parsed:', parsed); // Convert {"R":23,"I":25,"A":23,"S":16,"E":15,"C":22} to array format for chart riasecScores = [ { area: 'Realistic', score: parsed.R || 0 }, { area: 'Investigative', score: parsed.I || 0 }, { area: 'Artistic', score: parsed.A || 0 }, { area: 'Social', score: parsed.S || 0 }, { area: 'Enterprising', score: parsed.E || 0 }, { area: 'Conventional', score: parsed.C || 0 } ]; } catch (err) { console.error('[student-detail] RIASEC error:', err.message); } } delete student.riasec_scores; // Fetch career profiles if privacy allows let careerProfiles = []; if (privacy.share_career_profiles) { try { const [profiles] = await pool.execute(` SELECT id, career_name, status, start_date, retirement_start_date, college_enrollment_status, currently_working, scenario_title, created_at FROM career_profiles WHERE user_id = ? ORDER BY created_at DESC `, [userId]); console.log('[student-detail] Career profiles found:', profiles.length); careerProfiles = profiles; } catch (err) { console.error('[student-detail] Career profiles error:', err.message); } } // Fetch college profiles if privacy allows let collegeProfiles = []; if (privacy.share_college_profiles) { try { const [profiles] = await pool.execute(` SELECT id, selected_school, selected_program, program_type, college_enrollment_status, tuition, expected_graduation, expected_salary, created_at FROM college_profiles WHERE user_id = ? ORDER BY created_at DESC `, [userId]); console.log('[student-detail] College profiles found:', profiles.length); collegeProfiles = profiles; } catch (err) { console.error('[student-detail] College profiles error:', err.message); } } // Fetch career roadmap milestones if privacy allows let roadmapMilestones = []; if (privacy.share_roadmap) { try { const [milestones] = await pool.execute(` SELECT m.id, m.title, m.description, m.date, m.status, cp.career_name FROM milestones m JOIN career_profiles cp ON m.career_profile_id = cp.id WHERE cp.user_id = ? ORDER BY m.date ASC `, [userId]); console.log('[student-detail] Roadmap milestones found:', milestones.length); roadmapMilestones = milestones; } catch (err) { console.error('[student-detail] Roadmap milestones error:', err.message); } } // SECURITY: Remove user_id before sending to frontend delete student.user_id; if (privacy.user_id) delete privacy.user_id; return res.json({ student, privacy, careers, riasecScores, careerProfiles, collegeProfiles, roadmapMilestones }); } catch (err) { console.error('[student-detail] Error:', err.message); return res.status(500).json({ error: 'Failed to load student details' }); } }); // Reset password for student app.post('/api/admin/students/:studentId/reset-password', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const { studentId } = req.params; const db = pool.raw || pool; // Verify student belongs to this org (studentId is organization_students.id) const [studentCheck] = await pool.execute(` SELECT os.*, up.email FROM organization_students os JOIN user_profile up ON os.user_id = up.id WHERE os.organization_id = ? AND os.id = ? LIMIT 1 `, [orgId, studentId]); if (!studentCheck || studentCheck.length === 0) { return res.status(404).json({ error: 'Student not found' }); } const student = studentCheck[0]; // Decrypt email let email = student.email; try { email = decrypt(email); } catch (err) { // Email might not be encrypted } const emailNorm = String(email).trim().toLowerCase(); // Generate reset token const token = crypto.randomBytes(32).toString('hex'); const tokenHash = crypto.createHash('sha256').update(token).digest('hex'); const now = Date.now(); const RESET_TTL_MIN = 60; const expiresAt = now + RESET_TTL_MIN * 60 * 1000; // Store reset token in password_resets table await db.query( `INSERT INTO password_resets (email, token_hash, expires_at, created_at, ip) VALUES (?, ?, ?, ?, ?)`, [emailNorm, tokenHash, expiresAt, now, req.ip || 'admin-portal'] ); // Build reset link (using student portal URL) const baseUrl = (process.env.APTIVA_API_BASE || '').replace(/\/+$/, ''); const resetLink = `${baseUrl}/reset-password/${token}`; const emailText = `Your administrator has requested a password reset for your Aptiva account. Use the link below to reset your password (valid for ${RESET_TTL_MIN} minutes): ${resetLink} If you didn't request this, you can ignore this email.`; // Send email via SendGrid or log in dev mode if (SENDGRID_KEY) { await sgMail.send({ to: emailNorm, from: 'no-reply@aptivaai.com', subject: 'Reset your Aptiva password', text: emailText, html: `
${emailText}
` }); console.log(`[reset-password] Sent reset email to ${emailNorm}`); } else { console.log(`[DEV] Password reset link for ${emailNorm}: ${resetLink}`); } return res.json({ message: 'Password reset email sent' }); } catch (err) { console.error('[reset-password] Error:', err.message); return res.status(500).json({ error: 'Failed to send password reset' }); } }); // Deactivate student account app.post('/api/admin/students/:studentId/deactivate', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const { studentId } = req.params; const { status } = req.body; // Validate status const validStatuses = ['graduated', 'withdrawn', 'transferred', 'inactive']; const newStatus = validStatuses.includes(status) ? status : 'inactive'; // Update student enrollment status (studentId is organization_students.id) const [result] = await pool.execute(` UPDATE organization_students SET enrollment_status = ?, status_changed_date = NOW() WHERE organization_id = ? AND id = ? `, [newStatus, orgId, studentId]); if (result.affectedRows === 0) { return res.status(404).json({ error: 'Student not found' }); } console.log(`[deactivate-student] Student ${studentId} marked as ${newStatus}`); return res.json({ message: `Student marked as ${newStatus}` }); } catch (err) { console.error('[deactivate-student] Error:', err.message); return res.status(500).json({ error: 'Failed to update student status' }); } }); // Resend invitation to single student app.post('/api/admin/students/:studentId/resend-invitation', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const { studentId } = req.params; // Get student info (studentId is organization_students.id) const [students] = await pool.execute(` SELECT os.*, up.email, up.firstname, up.lastname FROM organization_students os JOIN user_profile up ON os.user_id = up.id WHERE os.organization_id = ? AND os.id = ? LIMIT 1 `, [orgId, studentId]); if (!students || students.length === 0) { return res.status(404).json({ error: 'Student not found' }); } const student = students[0]; const userId = student.user_id; // Decrypt fields let email = student.email; let firstname = student.firstname; try { email = decrypt(email); firstname = decrypt(firstname); } catch (err) { // Fields might not be encrypted } // Update invitation_sent_at await pool.execute(` UPDATE organization_students SET invitation_sent_at = NOW() WHERE organization_id = ? AND id = ? `, [orgId, studentId]); // Send invitation email sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId).catch(emailErr => { console.error(`[resend-invitation] Email send failed for ${email}:`, emailErr.message); }); return res.json({ message: 'Invitation resent' }); } catch (err) { console.error('[resend-invitation] Error:', err.message); return res.status(500).json({ error: 'Failed to resend invitation' }); } }); // Resend all pending invitations app.post('/api/admin/students/resend-all-invitations', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; // Get all pending invitation students const [students] = await pool.execute(` SELECT os.user_id, up.email, up.firstname FROM organization_students os JOIN user_profile up ON os.user_id = up.id WHERE os.organization_id = ? AND os.enrollment_status = 'pending_invitation' `, [orgId]); if (!students || students.length === 0) { return res.json({ message: 'No pending invitations to resend' }); } // Update all invitation_sent_at await pool.execute(` UPDATE organization_students SET invitation_sent_at = NOW() WHERE organization_id = ? AND enrollment_status = 'pending_invitation' `, [orgId]); // Send emails to all for (const student of students) { let email = student.email; let firstname = student.firstname; const userId = student.user_id; try { email = decrypt(email); firstname = decrypt(firstname); } catch (err) { // Not encrypted } sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId).catch(emailErr => { console.error(`[resend-all] Email send failed for ${email}:`, emailErr.message); }); } return res.json({ message: `Invitations resent to ${students.length} students` }); } catch (err) { console.error('[resend-all] Error:', err.message); return res.status(500).json({ error: 'Failed to resend invitations' }); } }); // Update email for bounced invitation app.post('/api/admin/students/:studentId/update-email', requireAdminAuth, async (req, res) => { const { email } = req.body; if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'Valid email required' }); } try { const orgId = req.admin.organizationId; const { studentId } = req.params; // Get student info (studentId is organization_students.id) const [students] = await pool.execute(` SELECT os.*, up.firstname FROM organization_students os JOIN user_profile up ON os.user_id = up.id WHERE os.organization_id = ? AND os.id = ? LIMIT 1 `, [orgId, studentId]); if (!students || students.length === 0) { return res.status(404).json({ error: 'Student not found' }); } const student = students[0]; const userId = student.user_id; // Extract for user_profile update // Decrypt firstname let firstname = student.firstname; try { firstname = decrypt(firstname); } catch (err) { // Not encrypted } // Encrypt new email const emailNorm = email.toLowerCase().trim(); const encEmail = encrypt(emailNorm); const emailLookup = generateHMAC(emailNorm); // Update email in user_profile await pool.execute(` UPDATE user_profile SET email = ?, email_lookup = ? WHERE id = ? `, [encEmail, emailLookup, userId]); // Update organization_students status back to pending_invitation await pool.execute(` UPDATE organization_students SET enrollment_status = 'pending_invitation', invitation_sent_at = NOW(), bounce_reason = NULL WHERE organization_id = ? AND id = ? `, [orgId, studentId]); // Send invitation email sendStudentInvitation(emailNorm, firstname, req.admin.organizationName, orgId, userId).catch(emailErr => { console.error(`[update-email] Email send failed for ${emailNorm}:`, emailErr.message); }); return res.json({ message: 'Email updated and invitation resent' }); } catch (err) { console.error('[update-email] Error:', err.message); return res.status(500).json({ error: 'Failed to update email' }); } }); app.post('/api/admin/students', requireAdminAuth, async (req, res) => { const { email, firstname, lastname, grade_level } = req.body; if (!email || !firstname || !lastname) { return res.status(400).json({ error: 'Email, firstname, and lastname required' }); } // Basic email validation if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'Invalid email format' }); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); const orgId = req.admin.organizationId; // Get organization state, area, type, and onboarding delay setting const [orgs] = await conn.execute( 'SELECT state, city, organization_type, onboarding_delay_days FROM organizations WHERE id = ? LIMIT 1', [orgId] ); const orgState = orgs[0]?.state || ''; const orgArea = orgs[0]?.city || ''; const orgType = orgs[0]?.organization_type || ''; const onboardingDelayDays = orgs[0]?.onboarding_delay_days || 14; // Default 14 days // Email lookup (HMAC) const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; const emailLookup = crypto .createHmac('sha256', EMAIL_INDEX_KEY) .update(String(email).trim().toLowerCase()) .digest('hex'); const [existingUsers] = await conn.execute( 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1', [emailLookup] ); let userId; if (existingUsers.length > 0) { userId = existingUsers[0].id; const [existingEnrollment] = await conn.execute( 'SELECT id FROM organization_students WHERE organization_id = ? AND user_id = ?', [orgId, userId] ); if (existingEnrollment.length > 0) { await conn.rollback(); return res.status(409).json({ error: 'Student already enrolled in organization' }); } } else { // Encrypt email const emailNorm = String(email).trim().toLowerCase(); const encEmail = encrypt(emailNorm); const [userResult] = await conn.execute( 'INSERT INTO user_profile (email, email_lookup, firstname, lastname, state, area, is_premium, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, NOW())', [encEmail, emailLookup, firstname, lastname, orgState, orgArea] ); userId = userResult.insertId; } // Calculate onboarding trigger date // K-12 Schools: delay for grades 11-12 only // Other institutions: trigger immediately for all students let triggerDate = null; if (orgType === 'K-12 School') { const shouldTriggerOnboarding = grade_level >= 11 && grade_level <= 12; triggerDate = shouldTriggerOnboarding ? new Date(Date.now() + onboardingDelayDays * 24 * 60 * 60 * 1000) : null; } else { // Non-K12: trigger immediately triggerDate = new Date(); } await conn.execute( 'INSERT INTO organization_students (organization_id, user_id, enrollment_status, enrollment_date, grade_level, onboarding_triggered_at) VALUES (?, ?, ?, NOW(), ?, ?)', [orgId, userId, 'active', grade_level || null, triggerDate] ); // Do NOT insert privacy settings - let modal prompt user to configure on first login await conn.commit(); // Send invitation email (async, don't block response) try { await sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId); } catch (emailErr) { console.error('[add-student] Email send failed:', emailErr.message); // Don't fail the request if email fails } return res.status(201).json({ message: 'Student added successfully', userId, email }); } catch (err) { await conn.rollback(); console.error('[add-student] Error:', err.message); if (err.stack) console.error(err.stack); return res.status(500).json({ error: 'Failed to add student' }); } finally { conn.release(); } }); app.post('/api/admin/students/bulk', requireAdminAuth, bulkUploadLimiter, async (req, res) => { const { students } = req.body; if (!Array.isArray(students) || students.length === 0) { return res.status(400).json({ error: 'Students array required' }); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); const orgId = req.admin.organizationId; const results = { added: 0, updated: 0, skipped: 0, errors: [] }; // Get organization state, area, type, and onboarding delay setting const [orgs] = await conn.execute( 'SELECT state, city, organization_type, onboarding_delay_days FROM organizations WHERE id = ? LIMIT 1', [orgId] ); const orgState = orgs[0]?.state || ''; const orgArea = orgs[0]?.city || ''; const orgType = orgs[0]?.organization_type || ''; const onboardingDelayDays = orgs[0]?.onboarding_delay_days || 14; // Default 14 days const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; for (const student of students) { const { email, firstname, lastname, grade_level } = student; if (!email || !firstname || !lastname) { results.errors.push({ email, error: 'Missing required fields' }); continue; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { results.errors.push({ email, error: 'Invalid email format' }); continue; } try { const emailNorm = String(email).trim().toLowerCase(); const emailLookup = crypto .createHmac('sha256', EMAIL_INDEX_KEY) .update(emailNorm) .digest('hex'); const [existingUsers] = await conn.execute( 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1', [emailLookup] ); let userId; if (existingUsers.length > 0) { userId = existingUsers[0].id; const [existingEnrollment] = await conn.execute( 'SELECT id FROM organization_students WHERE organization_id = ? AND user_id = ?', [orgId, userId] ); if (existingEnrollment.length > 0) { results.skipped++; continue; } } else { const encEmail = encrypt(emailNorm); const encFirstname = encrypt(firstname); const encLastname = encrypt(lastname); const [userResult] = await conn.execute( 'INSERT INTO user_profile (email, email_lookup, firstname, lastname, state, area, is_premium, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, NOW())', [encEmail, emailLookup, encFirstname, encLastname, orgState, orgArea] ); userId = userResult.insertId; } // Calculate onboarding trigger date // K-12 Schools: delay for grades 11-12 only // Other institutions: trigger immediately for all students let triggerDate = null; if (orgType === 'K-12 School') { const shouldTriggerOnboarding = grade_level >= 11 && grade_level <= 12; triggerDate = shouldTriggerOnboarding ? new Date(Date.now() + onboardingDelayDays * 24 * 60 * 60 * 1000) : null; } else { // Non-K12: trigger immediately triggerDate = new Date(); } await conn.execute( 'INSERT INTO organization_students (organization_id, user_id, enrollment_status, enrollment_date, grade_level, onboarding_triggered_at) VALUES (?, ?, ?, NOW(), ?, ?)', [orgId, userId, 'active', grade_level || null, triggerDate] ); // Do NOT insert privacy settings - let modal prompt user to configure on first login results.added++; // Send invitation email (async, don't block loop) sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId).catch(emailErr => { console.error(`[bulk-upload] Email send failed for ${email}:`, emailErr.message); }); } catch (err) { results.errors.push({ email, error: err.message }); } } await conn.commit(); return res.json({ message: 'Bulk upload completed', results }); } catch (err) { await conn.rollback(); console.error('[bulk-upload] Error:', err.message); if (err.stack) console.error(err.stack); return res.status(500).json({ error: 'Bulk upload failed' }); } finally { conn.release(); } }); app.patch('/api/admin/students/:studentId/status', requireAdminAuth, async (req, res) => { const { studentId } = req.params; const { status } = req.body; const validStatuses = ['active', 'graduated', 'withdrawn', 'transferred']; if (!validStatuses.includes(status)) { return res.status(400).json({ error: 'Invalid status. Must be: active, graduated, withdrawn, transferred' }); } try { const orgId = req.admin.organizationId; // studentId is organization_students.id const [existing] = await pool.execute( 'SELECT id FROM organization_students WHERE organization_id = ? AND id = ?', [orgId, studentId] ); if (existing.length === 0) { return res.status(404).json({ error: 'Student not found in organization' }); } await pool.execute( 'UPDATE organization_students SET enrollment_status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ?', [status, orgId, studentId] ); return res.json({ message: 'Student status updated', status }); } catch (err) { console.error('[update-status] Error:', err.message); return res.status(500).json({ error: 'Failed to update student status' }); } }); app.delete('/api/admin/students/:studentId', requireAdminAuth, requireSuperAdmin, async (req, res) => { const { studentId } = req.params; const conn = await pool.getConnection(); try { await conn.beginTransaction(); const orgId = req.admin.organizationId; // studentId is organization_students.id - get user_id for related deletes const [existing] = await conn.execute( 'SELECT id, user_id FROM organization_students WHERE organization_id = ? AND id = ?', [orgId, studentId] ); if (existing.length === 0) { await conn.rollback(); return res.status(404).json({ error: 'Student not found in organization' }); } const userId = existing[0].user_id; await conn.execute( 'DELETE FROM organization_students WHERE organization_id = ? AND id = ?', [orgId, studentId] ); await conn.execute( 'DELETE FROM student_privacy_settings WHERE organization_id = ? AND user_id = ?', [orgId, userId] ); await conn.execute( 'DELETE FROM user_emails WHERE organization_id = ? AND user_id = ?', [orgId, userId] ); await conn.commit(); return res.json({ message: 'Student removed from organization' }); } catch (err) { await conn.rollback(); console.error('[delete-student] Error:', err.message); if (err.stack) console.error(err.stack); return res.status(500).json({ error: 'Failed to remove student' }); } finally { conn.release(); } }); app.get('/api/admin/students/pending', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const [pending] = await pool.execute(` SELECT ue.email, ue.email_status, ue.sent_at, ue.bounced_at, up.firstname, up.lastname, up.created_at FROM user_emails ue JOIN user_profile up ON ue.user_id = up.id WHERE ue.organization_id = ? AND ue.email_status IN ('pending', 'bounced') ORDER BY ue.created_at DESC `, [orgId]); return res.json({ pending }); } catch (err) { console.error('[pending-invitations] Error:', err.message); return res.status(500).json({ error: 'Failed to load pending invitations' }); } }); app.post('/api/admin/students/resend-invitation', requireAdminAuth, resendLimiter, async (req, res) => { const { userId } = req.body; if (!userId) { return res.status(400).json({ error: 'userId required' }); } try { const orgId = req.admin.organizationId; const [result] = await pool.execute( 'UPDATE user_emails SET email_status = ?, sent_at = NOW() WHERE organization_id = ? AND user_id = ?', ['pending', orgId, userId] ); if (result.affectedRows === 0) { return res.status(404).json({ error: 'Email record not found' }); } // TODO: Trigger actual email send via SendGrid/Twilio return res.json({ message: 'Invitation resent' }); } catch (err) { console.error('[resend-invitation] Error:', err.message); return res.status(500).json({ error: 'Failed to resend invitation' }); } }); // ═══════════════════════════════════════════════════════════════════════════ // ORGANIZATION SETTINGS ENDPOINTS (Super Admin Only) // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/admin/organization/profile', requireAdminAuth, requireSuperAdmin, async (req, res) => { try { const orgId = req.admin.organizationId; const [orgs] = await pool.execute( 'SELECT * FROM organizations WHERE id = ? LIMIT 1', [orgId] ); // If organization doesn't exist, return defaults if (orgs.length === 0) { return res.json({ organization_name: req.admin.organizationName || '', organization_type: '', address: '', city: '', state: '', zip_code: '', primary_contact_name: '', primary_contact_email: '', primary_contact_phone: '' }); } return res.json(orgs[0]); } catch (err) { console.error('[get-organization] Error:', err.message); return res.status(500).json({ error: 'Failed to load organization' }); } }); app.put('/api/admin/organization/profile', requireAdminAuth, requireSuperAdmin, async (req, res) => { const allowedFields = [ 'organization_name', 'organization_type', 'address', 'city', 'state', 'zip_code', 'primary_contact_name', 'primary_contact_email', 'primary_contact_phone', 'onboarding_delay_days' ]; const updates = {}; for (const field of allowedFields) { if (req.body[field] !== undefined) { updates[field] = req.body[field]; } } if (Object.keys(updates).length === 0) { return res.status(400).json({ error: 'No valid fields to update' }); } try { const orgId = req.admin.organizationId; // Check if organization exists const [existing] = await pool.execute( 'SELECT id FROM organizations WHERE id = ? LIMIT 1', [orgId] ); if (existing.length === 0) { // Create organization if it doesn't exist - ensure required fields have defaults const defaults = { organization_name: req.admin.organizationName || 'Unnamed Organization', organization_type: 'Other', state: null, ...updates }; const fields = Object.keys(defaults); const placeholders = fields.map(() => '?').join(', '); const values = Object.values(defaults); await pool.execute( `INSERT INTO organizations (id, ${fields.join(', ')}, created_at) VALUES (?, ${placeholders}, NOW())`, [orgId, ...values] ); } else { // Update existing organization const setClause = Object.keys(updates).map(k => `${k} = ?`).join(', '); const values = [...Object.values(updates), orgId]; await pool.execute( `UPDATE organizations SET ${setClause}, updated_at = NOW() WHERE id = ?`, values ); } return res.json({ message: 'Organization profile updated' }); } catch (err) { console.error('[update-organization] Error:', err.message); return res.status(500).json({ error: 'Failed to update organization' }); } }); app.get('/api/admin/organization/calendar', requireAdminAuth, requireSuperAdmin, async (req, res) => { try { const orgId = req.admin.organizationId; const [calendars] = await pool.execute( 'SELECT * FROM academic_calendars WHERE organization_id = ? LIMIT 1', [orgId] ); // Return defaults if no calendar exists if (calendars.length === 0) { return res.json({ fall_roster_window_start: '', fall_roster_window_end: '', spring_roster_window_start: '', spring_roster_window_end: '', roster_change_threshold: 30 }); } return res.json(calendars[0]); } catch (err) { console.error('[get-calendar] Error:', err.message); return res.status(500).json({ error: 'Failed to load calendar' }); } }); app.put('/api/admin/organization/calendar', requireAdminAuth, requireSuperAdmin, async (req, res) => { const { fall_roster_window_start, fall_roster_window_end, spring_roster_window_start, spring_roster_window_end, roster_change_threshold } = req.body; const conn = await pool.getConnection(); try { await conn.beginTransaction(); const orgId = req.admin.organizationId; const [existing] = await conn.execute( 'SELECT id FROM academic_calendars WHERE organization_id = ? LIMIT 1', [orgId] ); if (existing.length === 0) { // Create new calendar await conn.execute( `INSERT INTO academic_calendars ( organization_id, fall_roster_window_start, fall_roster_window_end, spring_roster_window_start, spring_roster_window_end, roster_change_threshold, created_at ) VALUES (?, ?, ?, ?, ?, ?, NOW())`, [ orgId, fall_roster_window_start || null, fall_roster_window_end || null, spring_roster_window_start || null, spring_roster_window_end || null, roster_change_threshold || 30 ] ); } else { // Update existing calendar await conn.execute( `UPDATE academic_calendars SET fall_roster_window_start = ?, fall_roster_window_end = ?, spring_roster_window_start = ?, spring_roster_window_end = ?, roster_change_threshold = ?, updated_at = NOW() WHERE organization_id = ?`, [ fall_roster_window_start || null, fall_roster_window_end || null, spring_roster_window_start || null, spring_roster_window_end || null, roster_change_threshold || 30, orgId ] ); } await conn.commit(); return res.json({ message: 'Calendar updated' }); } catch (err) { await conn.rollback(); console.error('[update-calendar] Error:', err.message); if (err.stack) console.error(err.stack); return res.status(500).json({ error: 'Failed to update calendar' }); } finally { conn.release(); } }); app.get('/api/admin/organization/admins', requireAdminAuth, requireSuperAdmin, async (req, res) => { try { const orgId = req.admin.organizationId; const [admins] = await pool.execute(` SELECT oa.id, oa.user_id, oa.role, oa.created_at, up.firstname, up.lastname, up.email, up.last_login FROM organization_admins oa JOIN user_profile up ON oa.user_id = up.id WHERE oa.organization_id = ? ORDER BY oa.created_at DESC `, [orgId]); // Decrypt emails for (const admin of admins) { if (admin.email) { try { admin.email = decrypt(admin.email); } catch { // Leave as-is if not encrypted } } // SECURITY: Remove user_id before sending to frontend delete admin.user_id; } return res.json(admins); } catch (err) { console.error('[list-admins] Error:', err.message); if (err.stack) console.error(err.stack); return res.status(500).json({ error: 'Failed to load admins' }); } }); app.post('/api/admin/organization/admins', requireAdminAuth, requireSuperAdmin, async (req, res) => { const { email, firstname, lastname, role = 'staff_admin' } = req.body; if (!email || !firstname || !lastname) { return res.status(400).json({ error: 'Email, firstname, and lastname required' }); } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { return res.status(400).json({ error: 'Invalid email format' }); } // Schools can only create staff_admin - super_admin is created manually by Aptiva if (role === 'super_admin') { return res.status(403).json({ error: 'Cannot create Super Admin users. Contact AptivaAI support for Super Admin access.' }); } if (!['staff_admin'].includes(role)) { return res.status(400).json({ error: 'Invalid role. Must be: staff_admin' }); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); const orgId = req.admin.organizationId; const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; const emailNorm = String(email).trim().toLowerCase(); const emailLookup = crypto .createHmac('sha256', EMAIL_INDEX_KEY) .update(emailNorm) .digest('hex'); const [existingUsers] = await conn.execute( 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1', [emailLookup] ); let userId; if (existingUsers.length > 0) { userId = existingUsers[0].id; const [existingAdmin] = await conn.execute( 'SELECT id FROM organization_admins WHERE organization_id = ? AND user_id = ?', [orgId, userId] ); if (existingAdmin.length > 0) { await conn.rollback(); return res.status(409).json({ error: 'User is already an admin for this organization' }); } } else { const encEmail = encrypt(emailNorm); const [userResult] = await conn.execute( 'INSERT INTO user_profile (email, email_lookup, firstname, lastname, created_at) VALUES (?, ?, ?, ?, NOW())', [encEmail, emailLookup, firstname, lastname] ); userId = userResult.insertId; } await conn.execute( 'INSERT INTO organization_admins (organization_id, user_id, role) VALUES (?, ?, ?)', [orgId, userId, role] ); await conn.commit(); return res.status(201).json({ message: 'Admin added successfully', userId, email, role }); } catch (err) { await conn.rollback(); console.error('[add-admin] Error:', err.message); if (err.stack) console.error(err.stack); return res.status(500).json({ error: 'Failed to add admin' }); } finally { conn.release(); } }); app.patch('/api/admin/organization/admins/:adminId', requireAdminAuth, requireSuperAdmin, async (req, res) => { const { adminId } = req.params; const { role } = req.body; // Schools can only assign staff_admin role - super_admin is managed by Aptiva if (role === 'super_admin') { return res.status(403).json({ error: 'Cannot assign Super Admin role. Contact AptivaAI support for Super Admin access.' }); } if (!['staff_admin'].includes(role)) { return res.status(400).json({ error: 'Invalid role. Must be: staff_admin' }); } try { const orgId = req.admin.organizationId; const [currentAdmin] = await pool.execute( 'SELECT user_id FROM organization_admins WHERE id = ? AND organization_id = ?', [adminId, orgId] ); if (currentAdmin.length === 0) { return res.status(404).json({ error: 'Admin not found' }); } if (currentAdmin[0].user_id === req.admin.userId) { return res.status(403).json({ error: 'Cannot change your own role' }); } await pool.execute( 'UPDATE organization_admins SET role = ?, updated_at = NOW() WHERE id = ? AND organization_id = ?', [role, adminId, orgId] ); return res.json({ message: 'Admin role updated', role }); } catch (err) { console.error('[update-admin-role] Error:', err.message); return res.status(500).json({ error: 'Failed to update admin role' }); } }); app.delete('/api/admin/organization/admins/:adminId', requireAdminAuth, requireSuperAdmin, async (req, res) => { const { adminId } = req.params; try { const orgId = req.admin.organizationId; const [admin] = await pool.execute( 'SELECT user_id FROM organization_admins WHERE id = ? AND organization_id = ?', [adminId, orgId] ); if (admin.length === 0) { return res.status(404).json({ error: 'Admin not found' }); } if (admin[0].user_id === req.admin.userId) { return res.status(403).json({ error: 'Cannot remove yourself as admin' }); } const [superAdmins] = await pool.execute( 'SELECT COUNT(*) as count FROM organization_admins WHERE organization_id = ? AND role = ?', [orgId, 'super_admin'] ); const [removingAdmin] = await pool.execute( 'SELECT role FROM organization_admins WHERE id = ?', [adminId] ); if (superAdmins[0].count === 1 && removingAdmin[0].role === 'super_admin') { return res.status(403).json({ error: 'Cannot remove the last super admin' }); } await pool.execute( 'DELETE FROM organization_admins WHERE id = ? AND organization_id = ?', [adminId, orgId] ); return res.json({ message: 'Admin removed successfully' }); } catch (err) { console.error('[remove-admin] Error:', err.message); return res.status(500).json({ error: 'Failed to remove admin' }); } }); app.get('/api/admin/organization/billing', requireAdminAuth, requireSuperAdmin, async (req, res) => { try { const orgId = req.admin.organizationId; const [billing] = await pool.execute( 'SELECT * FROM organization_billing WHERE organization_id = ? ORDER BY created_at DESC', [orgId] ); return res.json({ billing }); } catch (err) { console.error('[get-billing] Error:', err.message); return res.status(500).json({ error: 'Failed to load billing information' }); } }); // ═══════════════════════════════════════════════════════════════════════════ // ROSTER UPDATE ENDPOINTS // ═══════════════════════════════════════════════════════════════════════════ app.post('/api/admin/roster/upload', requireAdminAuth, rosterUploadLimiter, async (req, res) => { const { students } = req.body; if (!Array.isArray(students) || students.length === 0) { return res.status(400).json({ error: 'Students array required' }); } const conn = await pool.getConnection(); try { await conn.beginTransaction(); const orgId = req.admin.organizationId; const submittedBy = req.admin.userId; const [currentCount] = await conn.execute( 'SELECT COUNT(*) as count FROM organization_students WHERE organization_id = ? AND enrollment_status = ?', [orgId, 'active'] ); const previousCount = currentCount[0].count; const results = { added: 0, updated: 0, skipped: 0, errors: [] }; // Get organization state, area, type, and onboarding delay setting const [orgs] = await conn.execute( 'SELECT state, city, organization_type, onboarding_delay_days FROM organizations WHERE id = ? LIMIT 1', [orgId] ); const orgState = orgs[0]?.state || ''; const orgArea = orgs[0]?.city || ''; const orgType = orgs[0]?.organization_type || ''; const onboardingDelayDays = orgs[0]?.onboarding_delay_days || 14; // Default 14 days const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET; for (const student of students) { const { email, firstname, lastname, status = 'active', grade_level } = student; if (!email || !firstname || !lastname) { results.errors.push({ email, error: 'Missing required fields' }); continue; } if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { results.errors.push({ email, error: 'Invalid email format' }); continue; } try { const emailNorm = String(email).trim().toLowerCase(); const emailLookup = crypto .createHmac('sha256', EMAIL_INDEX_KEY) .update(emailNorm) .digest('hex'); // Check for existing user in BOTH user_profile (primary email) AND user_emails (secondary emails) let userId = null; let isNewUser = false; // First check user_profile for primary email const [existingUsers] = await conn.execute( 'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1', [emailLookup] ); if (existingUsers.length > 0) { userId = existingUsers[0].id; } else { // Check user_emails for secondary emails const [secondaryEmails] = await conn.execute( 'SELECT user_id FROM user_emails WHERE email_lookup = ? LIMIT 1', [emailLookup] ); if (secondaryEmails.length > 0) { userId = secondaryEmails[0].user_id; } } // If user found (primary or secondary email), check if already enrolled in THIS org if (userId) { const [existingEnrollment] = await conn.execute( 'SELECT id FROM organization_students WHERE organization_id = ? AND user_id = ?', [orgId, userId] ); if (existingEnrollment.length > 0) { // Already enrolled - just update the record (grade level, status, etc.) await conn.execute( 'UPDATE organization_students SET enrollment_status = ?, grade_level = ?, updated_at = NOW() WHERE organization_id = ? AND user_id = ?', [status, grade_level || null, orgId, userId] ); results.updated++; // Don't send email for roster updates - skip to next student continue; } // User exists but not enrolled in this org yet - will enroll below and send email } else { // Email not found anywhere - create shell user_profile for invitation tracking const encEmail = encrypt(emailNorm); const encFirstname = encrypt(firstname); const encLastname = encrypt(lastname); const [userResult] = await conn.execute( 'INSERT INTO user_profile (email, email_lookup, firstname, lastname, state, area, is_premium, created_at) VALUES (?, ?, ?, ?, ?, ?, 1, NOW())', [encEmail, emailLookup, encFirstname, encLastname, orgState, orgArea] ); userId = userResult.insertId; isNewUser = true; } // Determine enrollment status: new users are pending_invitation, existing users are also pending_invitation until they accept const enrollmentStatus = 'pending_invitation'; // Calculate onboarding trigger date // K-12 Schools: delay for grades 11-12 only // Other institutions: trigger immediately for all students let triggerDate = null; if (orgType === 'K-12 School') { const shouldTriggerOnboarding = grade_level >= 11 && grade_level <= 12; triggerDate = shouldTriggerOnboarding ? new Date(Date.now() + onboardingDelayDays * 24 * 60 * 60 * 1000) : null; } else { // Non-K12: trigger immediately triggerDate = new Date(); } await conn.execute( 'INSERT INTO organization_students (organization_id, user_id, enrollment_status, enrollment_date, invitation_sent_at, grade_level, onboarding_triggered_at) VALUES (?, ?, ?, NOW(), ?, ?, ?)', [orgId, userId, enrollmentStatus, isNewUser ? new Date() : null, grade_level || null, triggerDate] ); // Do NOT insert privacy settings - let modal prompt user to configure on first login results.added++; // Send invitation email to ALL users (new and existing) sendStudentInvitation(email, firstname, req.admin.organizationName, orgId, userId, isNewUser).catch(emailErr => { console.error(`[roster-upload] Email send failed for ${email}:`, emailErr.message); }); } catch (err) { results.errors.push({ email, error: err.message }); } } // Get final count after processing (include active + pending, exclude deactivated) const [finalCount] = await conn.execute( 'SELECT COUNT(*) as count FROM organization_students WHERE organization_id = ? AND enrollment_status NOT IN (?, ?, ?)', [orgId, 'withdrawn', 'transferred', 'inactive'] ); const totalRosterSize = finalCount[0].count; let changePercentage = previousCount > 0 ? ((totalRosterSize - previousCount) / previousCount) * 100 : 100; // Ensure changePercentage is a valid number for decimal(5,2) if (!isFinite(changePercentage)) changePercentage = 0; changePercentage = Math.max(-999.99, Math.min(999.99, changePercentage)); // Insert roster update history record await conn.execute( `INSERT INTO roster_updates ( organization_id, admin_user_id, update_type, update_window_start, update_window_end, submitted_at, students_added, students_graduated, students_withdrawn, students_transferred, students_reactivated, total_roster_size, change_percentage, discount_eligible, flagged_for_review ) VALUES (?, ?, 'manual_upload', CURDATE(), CURDATE(), NOW(), ?, 0, 0, 0, 0, ?, ?, 0, 0)`, [orgId, submittedBy, results.added, totalRosterSize, changePercentage] ); await conn.commit(); return res.json({ message: 'Roster upload completed', changePercent: changePercentage.toFixed(2), results }); } catch (err) { await conn.rollback(); console.error('[roster-upload] Error:', err.message); if (err.stack) console.error(err.stack); return res.status(500).json({ error: `Roster upload failed: ${err.message}` }); } finally { conn.release(); } }); app.get('/api/admin/roster/history', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const [history] = await pool.execute(` SELECT ru.id, ru.submitted_at AS uploaded_at, ru.students_added, ru.students_graduated + ru.students_withdrawn + ru.students_transferred AS students_existing, ru.total_roster_size AS total_students_after, ru.change_percentage FROM roster_updates ru WHERE ru.organization_id = ? ORDER BY ru.submitted_at DESC LIMIT 50 `, [orgId]); // Return empty array if no history, not an error return res.json(history || []); } catch (err) { console.error('[roster-history] Error:', err.message); // Return empty array instead of error for better UX return res.json([]); } }); app.get('/api/admin/roster/discount-status', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const [latest] = await pool.execute( 'SELECT * FROM roster_updates WHERE organization_id = ? ORDER BY submitted_at DESC LIMIT 1', [orgId] ); if (latest.length === 0) { return res.json({ discountStatus: 'no_submissions', message: 'No roster submissions yet' }); } return res.json({ discountStatus: latest[0].discount_eligibility_status, changePercent: latest[0].change_percent, submittedAt: latest[0].submitted_at }); } catch (err) { console.error('[discount-status] Error:', err.message); return res.status(500).json({ error: 'Failed to load discount status' }); } }); // ═══════════════════════════════════════════════════════════════════════════ // ANALYTICS & REPORTS ENDPOINTS // ═══════════════════════════════════════════════════════════════════════════ app.get('/api/admin/reports/engagement', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const days = parseInt(req.query.days) || 30; const [dailyActive] = await pool.execute(` SELECT DATE(up.last_login) as date, COUNT(DISTINCT up.id) as active_users FROM organization_students os JOIN user_profile up ON os.user_id = up.id WHERE os.organization_id = ? AND os.enrollment_status = 'active' AND up.last_login >= DATE_SUB(NOW(), INTERVAL ? DAY) GROUP BY DATE(up.last_login) ORDER BY date DESC `, [orgId, days]); const [careerActivity] = await pool.execute(` SELECT DATE(cv.viewed_at) as date, COUNT(*) as views FROM career_views cv JOIN organization_students os ON cv.user_id = os.user_id WHERE os.organization_id = ? AND cv.viewed_at >= DATE_SUB(NOW(), INTERVAL ? DAY) GROUP BY DATE(cv.viewed_at) ORDER BY date DESC `, [orgId, days]); return res.json({ dailyActive, careerActivity }); } catch (err) { console.error('[engagement-report] Error:', err.message); return res.status(500).json({ error: 'Failed to generate engagement report' }); } }); app.get('/api/admin/reports/careers', requireAdminAuth, async (req, res) => { try { const orgId = req.admin.organizationId; const days = parseInt(req.query.days) || 90; const [careers] = await pool.execute(` SELECT cv.career_name, cv.career_soc_code, COUNT(DISTINCT cv.user_id) as unique_students, COUNT(*) as total_views FROM career_views cv JOIN organization_students os ON cv.user_id = os.user_id WHERE os.organization_id = ? AND cv.viewed_at >= DATE_SUB(NOW(), INTERVAL ? DAY) GROUP BY cv.career_soc_code, cv.career_name ORDER BY unique_students DESC LIMIT 50 `, [orgId, days]); return res.json({ careers }); } catch (err) { console.error('[career-trends] Error:', err.message); return res.status(500).json({ error: 'Failed to generate career trends report' }); } }); app.get('/api/admin/reports/export', requireAdminAuth, exportLimiter, async (req, res) => { try { const orgId = req.admin.organizationId; const [students] = await pool.execute(` SELECT os.id, os.enrollment_status, os.enrollment_date, up.firstname, up.lastname, up.email, up.last_login, up.created_at, sps.share_career_exploration, sps.share_interest_inventory, sps.share_college_research, sps.share_financial_data, sps.share_premium_features FROM organization_students os JOIN user_profile up ON os.user_id = up.id LEFT JOIN student_privacy_settings sps ON os.user_id = sps.user_id AND os.organization_id = sps.organization_id WHERE os.organization_id = ? ORDER BY up.lastname, up.firstname `, [orgId]); // Decrypt emails for (const student of students) { if (student.email) { try { student.email = decrypt(student.email); } catch { // Leave as-is } } } const headers = [ 'Student Number', 'First Name', 'Last Name', 'Email', 'Enrollment Status', 'Enrollment Date', 'Last Login', 'Created At', 'Share Career', 'Share Inventory', 'Share College', 'Share Financial', 'Share Premium' ]; let csv = headers.join(',') + '\n'; for (let i = 0; i < students.length; i++) { const student = students[i]; const row = [ i + 1, // Sequential student number instead of user_id `"${student.firstname}"`, `"${student.lastname}"`, `"${student.email}"`, student.enrollment_status, student.enrollment_date, student.last_login || '', student.created_at, student.share_career_exploration ? 'Y' : 'N', student.share_interest_inventory ? 'Y' : 'N', student.share_college_research ? 'Y' : 'N', student.share_financial_data ? 'Y' : 'N', student.share_premium_features ? 'Y' : 'N' ]; csv += row.join(',') + '\n'; } res.setHeader('Content-Type', 'text/csv'); res.setHeader('Content-Disposition', `attachment; filename="students_export_${Date.now()}.csv"`); return res.send(csv); } catch (err) { console.error('[export-students] Error:', err.message); return res.status(500).json({ error: 'Failed to export student data' }); } }); // ═══════════════════════════════════════════════════════════════════════════ // FINAL ERROR HANDLER // ═══════════════════════════════════════════════════════════════════════════ app.use((err, req, res, _next) => { if (res.headersSent) return; const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res); console.error(`[ref ${rid}]`, err?.message || err); return res.status(500).json({ error: 'Server error', ref: rid }); }); // ═══════════════════════════════════════════════════════════════════════════ // START SERVER // ═══════════════════════════════════════════════════════════════════════════ app.listen(PORT, '0.0.0.0', () => { console.log(`✓ Admin API (server4) running on port ${PORT}`); });