dev1/backend/server1.js
Josh c0a68eb81c
Some checks failed
ci/woodpecker/manual/woodpecker Pipeline failed
Big one - admin portal and DOB COPPA compliance
2025-10-30 10:28:38 +00:00

1927 lines
68 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import path from 'path';
import bodyParser from 'body-parser';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { initEncryption, encrypt, decrypt, verifyCanary, SENTINEL } from './shared/crypto/encryption.js';
import pool from './config/mysqlPool.js';
import sqlite3 from 'sqlite3';
import crypto from 'crypto';
import sgMail from '@sendgrid/mail';
import rateLimit from 'express-rate-limit';
import { readFile } from 'fs/promises'; // ← needed for /healthz
import { requireAuth } from './shared/requireAuth.js';
import cookieParser from 'cookie-parser';
import { sendSMS } from './utils/smsService.js';
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;`;
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,
SERVER1_PORT = 5000
} = 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);
}
// Unwrap / verify DEK and seed canary before serving traffic
try {
await initEncryption(); // <-- wrap in try/catch
const db = pool.raw || pool; // <-- bypass DAO wrapper for canary ops
const DB_POOL_SIZE = 12;
// quick connectivity check
await db.query('SELECT 1');
// ① ensure table
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);
}
// …the rest of your server: app = express(), middlewares, routes, app.listen()
/* ────────────────────────────────────────────────────────────────
Express app & middleware
---------------------------------------------------------------- */
const app = express();
const PORT = process.env.SERVER1_PORT || 5000;
app.disable('x-powered-by');
app.use(express.json({ limit: '1mb' }));
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.id || null
};
try { console.log(JSON.stringify(out)); } catch {}
});
next();
});
// ---- RUNTIME: minimal audit logging (API only, redacted) ----
function pickIp(req) {
// trust proxy already set in your apps
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;
// avoid logging PII: show keys + small snippet
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();
// correlation id
const rid = req.headers['x-request-id'] || crypto.randomUUID?.() || String(Date.now());
res.setHeader('X-Request-ID', rid);
const t0 = process.hrtime.bigint();
// capture minimal request data
const reqLog = {
ts: new Date().toISOString(),
rid,
ip: pickIp(req),
method: req.method,
path: req.path,
userId: req.userId || req.id || null, // populated by your auth middleware on many routes
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)
};
// one line JSON per request
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 = [
// server3
/^\/api\/premium\/resume\/optimize$/, // multer (multipart/form-data)
/^\/api\/premium\/stripe\/webhook$/, // Stripe (express.raw)
// add others if truly 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; // sane cap; adjust if you truly need more
const sanitize = (obj) => {
if (!obj || typeof obj !== 'object') return;
for (const k of Object.keys(obj)) {
const v = obj[k];
if (Array.isArray(v)) {
// keep first value semantics + bound array size
obj[k] = v.slice(0, MAX_ARRAY).filter(x => x !== '' && x != null);
if (obj[k].length === 1) obj[k] = obj[k][0]; // collapse singletons
}
}
};
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) => {
// dont double-send
if (res.headersSent) return;
// map a few known errors cleanly
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' });
}
// default: generic 500 without internals
console.error('[unhandled]', err?.message || err); // logs to stderr only
return res.status(500).json({ error: 'Server error' });
});
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
.split(',')
.map(o => o.trim())
.filter(Boolean);
function sessionCookieOptions() {
// All envs terminate TLS at Nginx; cookies must be Secure everywhere
const IS_HTTPS = true;
const CROSS_SITE = process.env.CROSS_SITE_COOKIES === '1'; // set to "1" if FE and API are different sites
const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined;
return {
httpOnly: true,
secure: IS_HTTPS, // <-- not secure in local dev
sameSite: CROSS_SITE ? 'none' : 'lax',
path: '/',
maxAge: 2 * 60 * 60 * 1000,
...(COOKIE_DOMAIN ? { domain: COOKIE_DOMAIN } : {}),
};
}
const COOKIE_NAME = process.env.SESSION_COOKIE_NAME || 'aptiva_session';
function fprPathFromEnv() {
const p = (process.env.DEK_PATH || '').trim();
return p ? path.join(path.dirname(p), 'dek.fpr') : null;
}
// 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(); // load/unlock DEK
await verifyCanary(pool); // DB + decrypt sentinel
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 (you can curl this to “see everything”)
app.get('/healthz', async (_req, res) => {
const out = {
service: process.env.npm_package_name || 'server',
version: process.env.IMG_TAG || null,
uptime_s: Math.floor(process.uptime()),
now: new Date().toISOString(),
checks: {
live: { ok: true }, // if we reached here, process is up
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);
});
// Password reset token table (MySQL)
try {
const db = pool.raw || pool;
await db.query(`
CREATE TABLE IF NOT EXISTS password_resets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
token_hash CHAR(64) NOT NULL,
expires_at BIGINT NOT NULL,
used_at BIGINT NULL,
created_at BIGINT NOT NULL,
ip VARCHAR(64) NULL,
KEY (email),
KEY (token_hash),
KEY (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('[AUTH] password_resets table ready');
} catch (e) {
console.error('FATAL creating password_resets table:', e?.message || e);
process.exit(1);
}
// Enable CORS with strict, env-driven origin allowlist (exact scheme+host)
app.use((req, res, next) => {
const origin = req.headers.origin || '';
if (!origin) return next(); // same-origin or server→server
if (!allowedOrigins.includes(origin)) {
// exact match only; no localhost/IP unless present in env for that env
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, OPTIONS');
if (req.method === 'OPTIONS') return res.status(204).end();
return next();
});
// keep tight on request
const pwRequestLimiter = rateLimit({
windowMs: 30 * 1000,
max: 1,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
// allow a couple retries on confirm
const pwConfirmLimiter = rateLimit({
windowMs: 30 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
// change-password inside the app
const pwChangeLimiter = rateLimit({
windowMs: 30 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
const pwDailyLimiter = rateLimit({
windowMs: 24 * 60 * 60 * 1000, // per day
max: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
async function setPasswordByEmail(email, bcryptHash) {
const sql = `
UPDATE user_auth ua
JOIN user_profile up ON up.id = ua.user_id
SET ua.hashed_password = ?, ua.password_changed_at = ?
WHERE up.email_lookup = ?
`;
const now = Date.now();
const [r] = await (pool.raw || pool).query(sql, [bcryptHash, now, emailLookup(email)]);
return !!r?.affectedRows;
}
// ---- Email index helper (HMAC-SHA256 of normalized email) ----
const EMAIL_INDEX_KEY = process.env.EMAIL_INDEX_SECRET || JWT_SECRET;
function emailLookup(email) {
return crypto
.createHmac('sha256', EMAIL_INDEX_KEY)
.update(String(email).trim().toLowerCase())
.digest('hex');
}
// ---- Username index helper (HMAC-SHA256 of normalized username) ----
const USERNAME_INDEX_KEY = process.env.USERNAME_INDEX_SECRET || JWT_SECRET;
function usernameLookup(username) {
return crypto
.createHmac('sha256', USERNAME_INDEX_KEY)
.update(String(username).trim().toLowerCase())
.digest('hex');
}
// ----- Password reset config (zero-config dev mode) -----
if (!process.env.APTIVA_API_BASE) {
console.error('FATAL: APTIVA_API_BASE missing set this to your web origin (e.g., https://dev1.aptivaai.com)');
process.exit(1);
}
const RESET_CONFIG = {
BASE_URL: process.env.APTIVA_API_BASE, // must be a public web origin
FROM: 'no-reply@aptivaai.com',
TTL_MIN: 60,
};
// --- SendGrid config (safe + simple) ---
const SENDGRID_KEY = String(
process.env.SUPPORT_SENDGRID_API_KEY ||
process.env.SENDGRID_API_KEY || // optional fallback
process.env.SUPPORT_SENDGRID_API_KEY_dev || // if you exported _dev
''
).trim();
const SENDGRID_ENABLED = SENDGRID_KEY.length > 0;
if (SENDGRID_ENABLED) {
sgMail.setApiKey(SENDGRID_KEY);
console.log('[MAIL] SendGrid enabled');
} else {
console.log('[MAIL] SendGrid disabled — will log reset links only');
}
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*]).{8,}$/;
const PASSWORD_HELP =
'Password must include at least 8 characters, one uppercase, one lowercase, one number, and one special character (!@#$%^&*).';
/* ────────────────────────────────────────────────────────────────
Verification helpers / rate limits
---------------------------------------------------------------- */
function absoluteWebBase() {
// You already require APTIVA_API_BASE above; reuse it to build magic links.
return String(RESET_CONFIG.BASE_URL || '').replace(/\/+$/, '');
}
const verifySendLimiter = rateLimit({
windowMs: 60 * 1000,
max: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
const verifyConfirmLimiter = rateLimit({
windowMs: 60 * 1000,
max: 6,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
});
// Change password (must be logged in)
app.post('/api/auth/password-change', requireAuth, pwChangeLimiter, async (req, res) => {
try {
const now = Date.now();
const userId = req.userId;
if (!userId) return res.status(401).json({ error: 'Not authenticated' });
const { currentPassword, newPassword } = req.body || {};
if (typeof currentPassword !== 'string' || typeof newPassword !== 'string') {
return res.status(400).json({ error: 'Invalid payload' });
}
if (!PASSWORD_REGEX.test(newPassword)) {
return res.status(400).json({ error: PASSWORD_HELP });
}
if (newPassword === currentPassword) {
return res.status(400).json({ error: 'New password must be different' });
}
const db = pool.raw || pool;
const [rows] = await db.query(
'SELECT hashed_password FROM user_auth WHERE user_id = ? ORDER BY id DESC LIMIT 1',
[userId]
);
const existing = rows?.[0];
if (!existing) return res.status(404).json({ error: 'Account not found' });
const ok = await bcrypt.compare(currentPassword, existing.hashed_password);
if (!ok) return res.status(403).json({ error: 'Current password is incorrect' });
const newHash = await bcrypt.hash(newPassword, 10);
// 🔧 store epoch ms to match requireAuths comparison
const [upd] = await db.query(
'UPDATE user_auth SET hashed_password = ?, password_changed_at = ? WHERE user_id = ?',
[newHash, now, userId]
);
if (!upd?.affectedRows) return res.status(500).json({ error: 'Password update failed' });
// Optional: revoke all refresh tokens for this user on password change
// await db.query('DELETE FROM refresh_tokens WHERE user_id = ?', [userId]);
return res.status(200).json({ ok: true });
} catch (e) {
console.error('[password-change]', e?.message || e);
return res.status(500).json({ error: 'Server error' });
}
});
/*Password reset request (MySQL)*/
app.post('/api/auth/password-reset/request', pwRequestLimiter, pwDailyLimiter, async (req, res) => {
try {
const email = String(req.body?.email || '').trim().toLowerCase();
// Always respond generically to avoid enumeration
const generic = () => res.status(200).json({ ok: true });
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return generic();
// Check if email exists (generic response regardless)
let exists = false;
try {
const [rows] = await (pool.raw || pool).query(
`SELECT ua.user_id
FROM user_auth ua
JOIN user_profile up ON up.id = ua.user_id
WHERE up.email_lookup = ?
LIMIT 1`,
[emailLookup(email)]
);
exists = !!rows?.length;
} catch { /* ignore */ }
// Only send if (a) we have SendGrid configured AND (b) email exists
if (exists) {
const token = crypto.randomBytes(32).toString('hex');
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const now = Date.now();
const expiresAt = now + RESET_CONFIG.TTL_MIN * 60 * 1000;
await (pool.raw || pool).query(
`INSERT INTO password_resets (email, token_hash, expires_at, created_at, ip)
VALUES (?, ?, ?, ?, ?)`,
[email, tokenHash, expiresAt, now, req.ip]
);
const base = RESET_CONFIG.BASE_URL.replace(/\/+$/, '');
const link = `${base}/reset-password/${token}`;
const text =
`We received a request to reset your Aptiva password.
If you requested this, use the link below (valid for ${RESET_CONFIG.TTL_MIN} minutes):
${link}
If you didnt request it, you can ignore this email.`;
if (SENDGRID_ENABLED) {
await sgMail.send({
to: email,
from: RESET_CONFIG.FROM,
subject: 'Reset your Aptiva password',
text,
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
});
} else {
// Zero-config dev mode: just log the link so you can click it
console.log(`[DEV] Password reset link for ${email}: ${link}`);
}
}
return generic();
} catch (e) {
console.error('[password-reset/request]', e?.message || e);
// Still generic
return res.status(200).json({ ok: true });
}
});
app.post('/api/auth/password-reset/confirm', pwConfirmLimiter, async (req, res) => {
try {
const { token, password } = req.body || {};
// 1) Validate input + password policy
if (typeof password !== 'string' || !PASSWORD_REGEX.test(password)) {
return res.status(400).json({ error: PASSWORD_HELP });
}
if (typeof token !== 'string' || !token) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
const now = Date.now();
const db = pool.raw || pool;
// 2) Lookup token row (not used, not expired)
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const [tokRows] = await db.query(
`SELECT * FROM password_resets
WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?
ORDER BY id DESC LIMIT 1`,
[tokenHash, now]
);
const tok = tokRows?.[0];
if (!tok) {
return res.status(400).json({ error: 'Invalid or expired token' });
}
// 3) Find user by email_lookup (HMAC of normalized email)
const emailNorm = String(tok.email).trim().toLowerCase();
const emailLookupVal = emailLookup(emailNorm);
const [userRows] = await db.query(
`SELECT ua.user_id, ua.hashed_password
FROM user_auth ua
JOIN user_profile up ON up.id = ua.user_id
WHERE up.email_lookup = ?
ORDER BY ua.id DESC
LIMIT 1`,
[emailLookupVal]
);
const user = userRows?.[0];
if (!user) {
// burn token anyway
await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]);
return res.status(400).json({ error: 'Account not found for this link' });
}
// 4) Prevent same-password reset
const same = await bcrypt.compare(password, user.hashed_password);
if (same) {
await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]);
return res.status(400).json({ error: 'New password must be different from the current password.' });
}
// 5) Update password (stamp epoch ms to match requireAuth)
const newHash = await bcrypt.hash(password, 10);
const [upd] = await db.query(
`UPDATE user_auth
SET hashed_password = ?, password_changed_at = ?
WHERE user_id = ?`,
[newHash, now, user.user_id]
);
if (!upd?.affectedRows) {
return res.status(500).json({ error: 'Password update failed' });
}
// 6) Burn the token
await db.query(`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`, [now, tok.id]);
return res.status(200).json({ ok: true });
} catch (e) {
console.error('[password-reset/confirm]', e?.message || e);
if (e?.stack) console.error(e.stack);
return res.status(500).json({ error: 'Server error' });
}
});
/* ------------------------------------------------------------------
AUTH STATUS (used by client gate)
------------------------------------------------------------------ */
app.get('/api/auth/status', requireAuth, async (req, res) => {
try {
const uid = req.userId;
const [rows] = await (pool.raw || pool).query(
'SELECT email_verified_at, phone_verified_at FROM user_profile WHERE id = ? LIMIT 1',
[uid]
);
const row = rows?.[0] || {};
return res.status(200).json({
is_authenticated: true,
email_verified_at: row.email_verified_at || null,
phone_verified_at: row.phone_verified_at || null
});
} catch (e) {
console.error('[auth/status]', e?.message || e);
return res.status(500).json({ error: 'Server error' });
}
});
/* ------------------------------------------------------------------
EMAIL VERIFICATION (send + confirm)
------------------------------------------------------------------ */
app.post('/api/auth/verify/email/send', requireAuth, verifySendLimiter, async (req, res) => {
try {
if (!SENDGRID_ENABLED) return res.status(503).json({ error: 'Email not configured' });
const uid = req.userId;
const [[row]] = await (pool.raw || pool).query('SELECT email FROM user_profile WHERE id=? LIMIT 1', [uid]);
const enc = row?.email;
if (!enc) return res.status(400).json({ error: 'No email on file' });
let emailPlain = '';
try { emailPlain = decrypt(enc); } catch { emailPlain = enc; }
const token = jwt.sign({ sub: String(uid), prp: 'verify_email' }, JWT_SECRET, { expiresIn: '30m' });
const link = `${absoluteWebBase()}/verify?t=${encodeURIComponent(token)}`;
const text =
`Verify your AptivaAI email by clicking the link below (expires in 30 minutes):
+${link}`;
await sgMail.send({
to: emailPlain,
from: RESET_CONFIG.FROM,
subject: 'Verify your email — AptivaAI',
text,
html: `<pre style="font-family: ui-monospace, Menlo, monospace; white-space: pre-wrap">${text}</pre>`
});
// In non-production, include token to enable E2E to complete verification without email I/O.
const extra = (process.env.ENV_NAME === 'prod') ? {} : { test_token: token };
return res.status(200).json({ ok: true, ...extra });
} catch (e) {
console.error('[verify/email/send]', e?.message || e);
return res.status(500).json({ error: 'Failed to send verification email' });
}
});
app.post('/api/auth/verify/email/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => {
try {
const { token } = req.body || {};
if (!token) return res.status(400).json({ error: 'Token required' });
let payload;
try { payload = jwt.verify(token, JWT_SECRET); }
catch { return res.status(400).json({ error: 'Invalid or expired token' }); }
if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_email') {
return res.status(400).json({ error: 'Token/user mismatch' });
}
const [r] = await (pool.raw || pool).query(
'UPDATE user_profile SET email_verified_at = UTC_TIMESTAMP() WHERE id = ?',
[req.userId]
);
return res.status(200).json({ ok: !!r?.affectedRows });
} catch (e) {
console.error('[verify/email/confirm]', e?.message || e);
return res.status(500).json({ error: 'Failed to confirm email' });
}
});
/* ------------------------------------------------------------------
PHONE VERIFICATION (send + confirm) — optional
------------------------------------------------------------------ */
app.post('/api/auth/verify/phone/send', requireAuth, verifySendLimiter, async (req, res) => {
try {
const { phone_e164, consent } = req.body || {};
if (!phone_e164 || !/^\+1\d{10}$/.test(phone_e164)) {
return res.status(400).json({ error: 'Phone must be +1 followed by 10 digits' });
}
if (!consent) return res.status(400).json({ error: 'consent_required' });
/// persist phone + record explicit consent using existing flag
await (pool.raw || pool).query(
'UPDATE user_profile SET phone_e164=?, sms_opt_in=1 WHERE id=?',
[phone_e164, req.userId]
);
const code = String(Math.floor(100000 + Math.random() * 900000));
await sendSMS({
to: phone_e164,
body: `AptivaAI code: ${code}. Expires in 10 minutes. Reply STOP to cancel, HELP for help.`
});
// store short-lived challenge in HttpOnly cookie (10 min)
const tok = jwt.sign({ sub: String(req.userId), prp: 'verify_phone', code }, JWT_SECRET, { expiresIn: '10m' });
res.cookie('aptiva_phone_vc', tok, { ...sessionCookieOptions(), maxAge: 10 * 60 * 1000 });
return res.status(200).json({ ok: true });
} catch (e) {
console.error('[verify/phone/send]', e?.message || e);
return res.status(500).json({ error: 'Failed to send code' });
}
});
app.post('/api/auth/verify/phone/confirm', requireAuth, verifyConfirmLimiter, async (req, res) => {
try {
const { code } = req.body || {};
if (!code) return res.status(400).json({ error: 'Code required' });
const tok = req.cookies?.aptiva_phone_vc;
if (!tok) return res.status(400).json({ error: 'No challenge issued' });
let payload;
try { payload = jwt.verify(tok, JWT_SECRET); }
catch { return res.status(400).json({ error: 'Challenge expired' }); }
if (String(payload?.sub) !== String(req.userId) || payload?.prp !== 'verify_phone') {
return res.status(400).json({ error: 'Challenge mismatch' });
}
if (String(payload?.code) !== String(code)) {
return res.status(400).json({ error: 'Invalid code' });
}
const [r] = await (pool.raw || pool).query(
'UPDATE user_profile SET phone_verified_at = UTC_TIMESTAMP() WHERE id = ?',
[req.userId]
);
res.clearCookie('aptiva_phone_vc', sessionCookieOptions());
return res.status(200).json({ ok: !!r?.affectedRows });
} catch (e) {
if (String(e?.code) === '21610') { // Twilio: user replied STOP
try { await (pool.raw || pool).query('UPDATE user_profile SET sms_opt_in=0 WHERE id=?', [req.userId]); } catch {}
return res.status(409).json({ error: 'user_opted_out' });
}
console.error('[verify/phone/send]', e?.message || e);
return res.status(500).json({ error: 'Failed to confirm phone' });
}
});
/* ------------------------------------------------------------------
VALIDATE INVITATION TOKEN (for B2B student invitations)
------------------------------------------------------------------ */
app.post('/api/validate-invite', async (req, res) => {
const { token } = req.body;
if (!token) {
return res.status(400).json({ error: 'Invitation token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.prp !== 'student_invite') {
return res.status(400).json({ error: 'Invalid invitation token' });
}
// Verify user still exists and is pending
const [users] = await pool.query(
'SELECT id, email, firstname, lastname, username FROM user_profile WHERE id = ? LIMIT 1',
[decoded.userId]
);
if (!users.length) {
return res.status(404).json({ error: 'Invitation not found or expired' });
}
const user = users[0];
// Check if already completed signup (username is not NULL)
if (user.username) {
return res.status(400).json({ error: 'This invitation has already been used. Please sign in instead.' });
}
// Verify enrollment exists and is pending
const [enrollment] = await pool.query(
'SELECT enrollment_status FROM organization_students WHERE organization_id = ? AND user_id = ?',
[decoded.organizationId, decoded.userId]
);
if (!enrollment.length) {
return res.status(404).json({ error: 'Invitation not found' });
}
// Decrypt email and names for pre-fill
let email = user.email;
let firstname = user.firstname;
let lastname = user.lastname;
try {
email = decrypt(email);
} catch (err) {
// Not encrypted or decryption failed
}
try {
if (firstname && firstname.startsWith('gcm:')) {
firstname = decrypt(firstname);
}
} catch (err) {
// Not encrypted or decryption failed
}
try {
if (lastname && lastname.startsWith('gcm:')) {
lastname = decrypt(lastname);
}
} catch (err) {
// Not encrypted or decryption failed
}
return res.json({
valid: true,
email: email,
firstname: firstname,
lastname: lastname,
userId: decoded.userId,
organizationId: decoded.organizationId
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
}
console.error('[validate-invite] Error:', err.message);
return res.status(400).json({ error: 'Invalid invitation token' });
}
});
/* ------------------------------------------------------------------
LINK EXISTING ACCOUNT TO ORGANIZATION (for existing users)
------------------------------------------------------------------ */
app.post('/api/link-account', requireAuth, async (req, res) => {
const { token } = req.body;
const userId = req.userId; // From requireAuth middleware
if (!token) {
return res.status(400).json({ error: 'Invitation token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.prp !== 'student_invite') {
return res.status(400).json({ error: 'Invalid invitation token' });
}
if (decoded.isNewUser !== false) {
return res.status(400).json({ error: 'This invitation is for a new account, not account linking' });
}
// Verify the token's userId matches the logged-in user
if (decoded.userId !== userId) {
return res.status(400).json({ error: 'This invitation is for a different account' });
}
const organizationId = decoded.organizationId;
// Check if already enrolled
const [existing] = await pool.query(
'SELECT id, enrollment_status, invitation_sent_at FROM organization_students WHERE organization_id = ? AND user_id = ? LIMIT 1',
[organizationId, userId]
);
if (existing.length > 0) {
// Already enrolled - update status to active and set invitation_sent_at if null
const invitationSentAt = existing[0].invitation_sent_at || new Date();
await pool.query(
'UPDATE organization_students SET enrollment_status = ?, invitation_sent_at = ?, invitation_accepted_at = NOW(), updated_at = NOW() WHERE organization_id = ? AND user_id = ?',
['active', invitationSentAt, organizationId, userId]
);
} else {
return res.status(404).json({ error: 'Enrollment record not found. Please contact your administrator.' });
}
// Ensure user has premium access (org students get premium)
await pool.query(
'UPDATE user_profile SET is_premium = 1 WHERE id = ?',
[userId]
);
return res.json({
message: 'Account linked successfully',
organizationId
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
}
console.error('[link-account] Error:', err.message);
return res.status(400).json({ error: 'Invalid invitation token' });
}
});
/* ------------------------------------------------------------------
LINK SECONDARY EMAIL TO EXISTING ACCOUNT (for new user invitations with different email)
------------------------------------------------------------------ */
app.post('/api/link-secondary-email', requireAuth, async (req, res) => {
const { token } = req.body;
const loggedInUserId = req.userId; // From requireAuth middleware
if (!token) {
return res.status(400).json({ error: 'Invitation token required' });
}
try {
const decoded = jwt.verify(token, JWT_SECRET);
if (decoded.prp !== 'student_invite') {
return res.status(400).json({ error: 'Invalid invitation token' });
}
if (decoded.isNewUser !== true) {
return res.status(400).json({ error: 'This invitation is for an existing email, not secondary email linking' });
}
const shellUserId = decoded.userId; // The shell user_id created during roster upload
const invitationEmail = decoded.email;
const organizationId = decoded.organizationId;
// Get the shell user's encrypted email and firstname for user_emails
const [shellUser] = await pool.query(
'SELECT email, firstname FROM user_profile WHERE id = ? LIMIT 1',
[shellUserId]
);
if (!shellUser || shellUser.length === 0) {
return res.status(404).json({ error: 'Invitation not found or expired' });
}
// Add the invitation email to user_emails as secondary email for the logged-in user
const emailNorm = String(invitationEmail).trim().toLowerCase();
const emailLookupVal = emailLookup(emailNorm);
const encEmail = encrypt(emailNorm);
await pool.query(
'INSERT INTO user_emails (user_id, email, email_lookup, is_primary, is_verified, verified_at) VALUES (?, ?, ?, 0, 1, NOW())',
[loggedInUserId, encEmail, emailLookupVal]
);
// Get invitation_sent_at from the enrollment record
const [enrollment] = await pool.query(
'SELECT invitation_sent_at FROM organization_students WHERE user_id = ? AND organization_id = ? LIMIT 1',
[shellUserId, organizationId]
);
const invitationSentAt = enrollment[0]?.invitation_sent_at || new Date();
// Update organization_students to point to the real user_id instead of shell
await pool.query(
'UPDATE organization_students SET user_id = ?, enrollment_status = ?, invitation_sent_at = ?, invitation_accepted_at = NOW(), updated_at = NOW() WHERE user_id = ? AND organization_id = ?',
[loggedInUserId, 'active', invitationSentAt, shellUserId, organizationId]
);
// Ensure user has premium access (org students get premium)
await pool.query(
'UPDATE user_profile SET is_premium = 1 WHERE id = ?',
[loggedInUserId]
);
// Delete the shell user_profile and any related records
await pool.query('DELETE FROM user_profile WHERE id = ?', [shellUserId]);
return res.json({
message: 'Secondary email linked successfully',
organizationId
});
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(400).json({ error: 'Invitation link has expired. Please contact your administrator.' });
}
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'This email is already linked to an account.' });
}
console.error('[link-secondary-email] Error:', err.message);
return res.status(400).json({ error: 'Failed to link secondary email' });
}
});
/* ------------------------------------------------------------------
USER REGISTRATION (MySQL)
------------------------------------------------------------------ */
app.post('/api/register', async (req, res) => {
const {
username, password, firstname, lastname, email,
date_of_birth, // NEW: DOB for COPPA compliance
zipcode, state, area, career_situation, phone_e164, sms_opt_in,
inviteToken // NEW: Invitation token from organization admin
} = req.body;
if (!username || !password || !firstname || !lastname || !email || !date_of_birth || !zipcode || !state || !area) {
return res.status(400).json({ error: 'Missing required fields.' });
}
// Validate DOB format and age (COPPA compliance - must be 13+)
const dobRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dobRegex.test(date_of_birth)) {
return res.status(400).json({ error: 'Invalid date of birth format. Expected YYYY-MM-DD.' });
}
const birthDate = new Date(date_of_birth);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
if (age < 13) {
return res.status(403).json({ error: 'You must be at least 13 years old to use AptivaAI.' });
}
if (sms_opt_in && !/^\+\d{8,15}$/.test(phone_e164 || '')) {
return res.status(400).json({ error: 'Phone must be +E.164 format.' });
}
try {
let userId;
let isInvitedStudent = false;
let organizationId = null;
// Check if this is completing an invitation
if (inviteToken) {
try {
const decoded = jwt.verify(inviteToken, JWT_SECRET);
if (decoded.prp === 'student_invite') {
userId = decoded.userId;
organizationId = decoded.organizationId;
isInvitedStudent = true;
// Verify user still exists and username is NULL
const [existingUser] = await pool.query(
'SELECT id, username FROM user_profile WHERE id = ? LIMIT 1',
[userId]
);
if (!existingUser.length) {
return res.status(404).json({ error: 'Invitation not found or expired.' });
}
if (existingUser[0].username) {
return res.status(400).json({ error: 'This invitation has already been used. Please sign in instead.' });
}
// Update existing user_profile with username and other details
await pool.query(
'UPDATE user_profile SET username = ?, zipcode = ?, state = ?, area = ?, career_situation = ?, phone_e164 = ?, sms_opt_in = ? WHERE id = ?',
[username, zipcode, state, area, career_situation || null, phone_e164 || null, sms_opt_in ? 1 : 0, userId]
);
// Create user_auth for login (with DOB for COPPA compliance)
const hashedPassword = await bcrypt.hash(password, 10);
const usernameLookupVal = usernameLookup(username);
await pool.query(
'INSERT INTO user_auth (user_id, username, username_lookup, hashed_password, date_of_birth, age_verified_at) VALUES (?, ?, ?, ?, ?, NOW())',
[userId, username, usernameLookupVal, hashedPassword, date_of_birth]
);
// Update organization_students status to active
await pool.query(
'UPDATE organization_students SET enrollment_status = ?, invitation_accepted_at = NOW() WHERE organization_id = ? AND user_id = ?',
['active', organizationId, userId]
);
console.log(`[register] Invitation completed for user ${userId} in org ${organizationId}`);
}
} catch (tokenErr) {
console.error('[register] Invalid invite token:', tokenErr.message);
// Continue as normal signup if token is invalid
isInvitedStudent = false;
}
}
// If not invited student, create new user (existing logic)
if (!isInvitedStudent) {
const hashedPassword = await bcrypt.hash(password, 10);
const emailNorm = String(email).trim().toLowerCase();
const encEmail = encrypt(emailNorm);
const emailLookupVal = emailLookup(emailNorm);
// Check for duplicate email (except for test account bypass)
if (emailNorm !== 'jcoakley@aptivaai.com') {
const [existingUser] = await pool.query(
'SELECT id FROM user_profile WHERE email_lookup = ? LIMIT 1',
[emailLookupVal]
);
if (existingUser.length > 0) {
return res.status(409).json({ error: 'An account with this email already exists.' });
}
} else {
console.log('[register] Test account bypass for jcoakley@aptivaai.com - allowing duplicate');
}
const [resultProfile] = await pool.query(`
INSERT INTO user_profile
(username, firstname, lastname, email, email_lookup, zipcode, state, area,
career_situation, phone_e164, sms_opt_in)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
username, firstname, lastname, encEmail, emailLookupVal,
zipcode, state, area, career_situation || null,
phone_e164 || null, sms_opt_in ? 1 : 0
]);
userId = resultProfile.insertId;
const usernameLookupVal = usernameLookup(username);
const authQuery = `INSERT INTO user_auth (user_id, username, username_lookup, hashed_password, date_of_birth, age_verified_at) VALUES (?, ?, ?, ?, ?, NOW())`;
await pool.query(authQuery, [userId, username, usernameLookupVal, hashedPassword, date_of_birth]);
}
const token = jwt.sign({ id: userId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
return res.status(201).json({
message: 'User registered successfully',
profileId: userId,
token,
user: {
username, firstname, lastname, email: email, zipcode, state, area,
career_situation, phone_e164: phone_e164 || null, sms_opt_in: !!sms_opt_in
}
});
} catch (err) {
// If you added UNIQUE idx on email_lookup, surface a nicer error for duplicates:
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'An account with this email already exists.' });
}
console.error('Error during registration:', err.message);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------
SIGN-IN (MySQL)
------------------------------------------------------------------ */
/**
* POST /api/signin
* Body: { username, password }
* Returns JWT signed with user_profile.id
*/
const signinLimiter = rateLimit({ windowMs: 15*60*1000, max: 50, standardHeaders: true, legacyHeaders: false });
app.post('/api/signin', signinLimiter, async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Both username and password are required' });
}
// Use username_lookup hash for querying (username is encrypted)
const usernameLookupVal = usernameLookup(username);
// Only fetch what you need to verify creds
const query = `
SELECT ua.user_id AS userProfileId, ua.hashed_password
FROM user_auth ua
WHERE ua.username_lookup = ?
LIMIT 1
`;
try {
const [results] = await pool.query(query, [usernameLookupVal]);
if (!results || results.length === 0) {
return res.status(401).json({ error: 'Invalid username or password' });
}
const { userProfileId, hashed_password } = results[0];
const isMatch = await bcrypt.compare(password, hashed_password);
if (!isMatch) {
return res.status(401).json({ error: 'Invalid username or password' });
}
// Update last_login timestamp
await pool.execute('UPDATE user_profile SET last_login = NOW() WHERE id = ?', [userProfileId]);
// Cookie-based session only; do NOT return id/token/user in body
const token = jwt.sign({ id: userProfileId }, JWT_SECRET, { expiresIn: '2h' });
res.cookie(COOKIE_NAME, token, sessionCookieOptions());
return res.status(200).json({ message: 'Login successful' });
} catch (err) {
console.error('Error querying user_auth:', err.message);
return res.status(500).json({ error: 'Failed to query user authentication data' });
}
});
app.post('/api/logout', (_req, res) => {
res.clearCookie(COOKIE_NAME, sessionCookieOptions());
return res.status(200).json({ ok: true });
});
/* ------------------------------------------------------------------
CHECK USERNAME (MySQL)
------------------------------------------------------------------ */
app.get('/api/check-username/:username', async (req, res) => {
const { username } = req.params;
try {
const [results] = await pool.query(`SELECT username FROM user_auth WHERE username = ?`, [username]);
res.status(200).json({ exists: results.length > 0 });
} catch (err) {
console.error('Error checking username:', err.message);
res.status(500).json({ error: 'Database error' });
}
});
/* ------------------------------------------------------------------
UPSERT USER PROFILE (MySQL)
------------------------------------------------------------------ */
app.post('/api/user-profile', requireAuth, async (req, res) => {
const profileId = req.userId; // from requireAuth middleware
const {
userName,
firstName,
lastName,
email,
zipCode,
state,
area,
careerSituation,
interest_inventory_answers,
riasec: riasec_scores,
career_priorities,
career_list,
phone_e164,
sms_opt_in,
sms_reminders_opt_in
} = req.body;
try {
const [rows] = await pool.query(`SELECT * FROM user_profile WHERE id = ?`, [profileId]);
const existing = rows[0];
if (!existing &&
(!firstName || !lastName || !email || !zipCode || !state || !area)) {
return res.status(400).json({ error: 'All fields are required for initial profile creation.' });
}
const finalAnswers = (interest_inventory_answers !== undefined)
? interest_inventory_answers
: existing?.interest_inventory_answers ?? null;
const finalCareerPriorities = (career_priorities !== undefined)
? career_priorities
: existing?.career_priorities ?? null;
const finalCareerList = (career_list !== undefined)
? career_list
: existing?.career_list ?? null;
const finalUserName = (userName !== undefined)
? userName
: existing?.username ?? null;
const finalRiasec = (riasec_scores !== undefined)
? JSON.stringify(riasec_scores)
: existing?.riasec_scores ?? null;
console.log('[user-profile] RIASEC debug:', {
riasec_scores_from_body: riasec_scores,
riasec_scores_undefined: riasec_scores === undefined,
finalRiasec: finalRiasec,
finalRiasec_length: finalRiasec?.length
});
// Normalize email and compute lookup iff email is provided (or keep existing)
const safeDecrypt = (v) => { try { return decrypt(v); } catch { return v; } };
const emailNorm = email
? String(email).trim().toLowerCase()
: existing?.email ? safeDecrypt(existing.email) : null;
const encEmail = email ? encrypt(emailNorm) : existing?.email;
const emailLookupVal = email ? emailLookup(emailNorm) : existing?.email_lookup ?? null;
const phoneFinal = (phone_e164 !== undefined) ? (phone_e164 || null) : (existing?.phone_e164 ?? null);
const smsOptFinal = (typeof sms_opt_in === 'boolean')
? (sms_opt_in ? 1 : 0)
: (existing?.sms_opt_in ?? 0);
const smsRemindersFinal = (typeof sms_reminders_opt_in === 'boolean')
? (sms_reminders_opt_in ? 1 : 0)
: (existing?.sms_reminders_opt_in ?? 0);
if (existing) {
const updateQuery = `
UPDATE user_profile
SET username = ?,
firstname = ?,
lastname = ?,
email = ?,
email_lookup = ?,
zipcode = ?,
state = ?,
area = ?,
career_situation = ?,
interest_inventory_answers = ?,
riasec_scores = ?,
career_priorities = ?,
career_list = ?,
phone_e164 = ?,
sms_opt_in = ?,
sms_reminders_opt_in = ?,
sms_reminders_opt_in_at =
CASE
WHEN ? = 1 AND (sms_reminders_opt_in IS NULL OR sms_reminders_opt_in = 0)
THEN UTC_TIMESTAMP()
ELSE sms_reminders_opt_in_at
END
WHERE id = ?
`;
const params = [
finalUserName,
firstName ?? existing.firstname,
lastName ?? existing.lastname,
encEmail,
emailLookupVal,
zipCode ?? existing.zipcode,
state ?? existing.state,
area ?? existing.area,
careerSituation ?? existing.career_situation,
finalAnswers,
finalRiasec,
finalCareerPriorities,
finalCareerList,
phoneFinal,
smsOptFinal,
smsRemindersFinal,
smsRemindersFinal,
profileId
];
await pool.query(updateQuery, params);
return res.status(200).json({ message: 'User profile updated successfully' });
} else {
// INSERT branch
const insertQuery = `
INSERT INTO user_profile
(id, username, firstname, lastname, email, email_lookup, zipcode, state, area,
career_situation, interest_inventory_answers, riasec_scores,
career_priorities, career_list, phone_e164, sms_opt_in, sms_reminders_opt_in,
sms_reminders_opt_in_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?, ?,
CASE WHEN ? = 1 THEN UTC_TIMESTAMP() ELSE NULL END)
`;
const params = [
profileId,
finalUserName,
firstName,
lastName,
encEmail,
emailLookupVal,
zipCode,
state,
area,
careerSituation ?? null,
finalAnswers,
finalRiasec,
finalCareerPriorities,
finalCareerList,
phoneFinal,
smsOptFinal,
smsRemindersFinal,
smsRemindersFinal,
];
await pool.query(insertQuery, params);
return res.status(201).json({ message: 'User profile created successfully', id: profileId });
}
} catch (err) {
if (err.code === 'ER_DUP_ENTRY') {
return res.status(409).json({ error: 'An account with this email already exists.' });
}
console.error('Error upserting user profile:', err.message);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------
FETCH USER PROFILE (MySQL) — safe, minimal, no id
------------------------------------------------------------------ */
app.get('/api/user-profile', requireAuth, async (req, res) => {
const profileId = req.userId;
try {
// Optional minimal-field mode: /api/user-profile?fields=a,b,c
const raw = (req.query.fields || '').toString().trim();
if (raw) {
// No 'id' here on purpose
const ALLOW = new Set([
'username','firstname','lastname','career_situation',
'is_premium','is_pro_premium',
'state','area','zipcode',
'career_priorities','interest_inventory_answers','riasec_scores','career_list',
'email',
'phone_e164',
'sms_opt_in',
'email_verified_at',
'phone_verified_at'
]);
const requested = raw.split(',').map(s => s.trim()).filter(Boolean);
const cols = requested.filter(c => ALLOW.has(c));
if (cols.length === 0) {
return res.status(400).json({ error: 'no_allowed_fields' });
}
const sql = `SELECT ${cols.join(', ')} FROM user_profile WHERE id = ? LIMIT 1`;
const [rows] = await pool.query(sql, [profileId]);
const row = rows && rows[0] ? rows[0] : null; // <-- declare BEFORE using
if (!row) return res.status(404).json({ error: 'User profile not found' });
// Decrypt only if explicitly requested and present
if (cols.includes('email') && row.email) {
try { row.email = decrypt(row.email); } catch {}
}
// Phone may be encrypted; normalize sms_opt_in to boolean
if (cols.includes('phone_e164') && typeof row.phone_e164 === 'string' && row.phone_e164.startsWith('gcm:')) {
try { row.phone_e164 = decrypt(row.phone_e164); } catch {}
}
if (cols.includes('sms_opt_in')) {
row.sms_opt_in = !!row.sms_opt_in; // BIT/TINYINT → boolean
}
return res.status(200).json(row);
}
// Legacy fallback: return only something harmless (no id/email)
const [rows] = await pool.query(
'SELECT firstname FROM user_profile WHERE id = ? LIMIT 1',
[profileId]
);
const row = rows && rows[0] ? rows[0] : null;
if (!row) return res.status(404).json({ error: 'User profile not found' });
return res.status(200).json(row);
} catch (err) {
console.error('Error fetching user profile:', err?.message || err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------
CHECK ONBOARDING STATUS (MySQL)
------------------------------------------------------------------ */
app.get('/api/onboarding-status', requireAuth, async (req, res) => {
const userId = req.userId;
try {
// Check if user is in an organization and if onboarding should be triggered
const [rows] = await pool.query(`
SELECT
os.onboarding_triggered_at,
os.onboarding_completed,
os.grade_level
FROM organization_students os
WHERE os.user_id = ?
AND os.enrollment_status NOT IN ('withdrawn', 'transferred', 'inactive')
ORDER BY os.enrollment_date DESC
LIMIT 1
`, [userId]);
if (!rows || rows.length === 0) {
// User not in any organization - no onboarding needed
return res.json({ shouldTrigger: false });
}
const { onboarding_triggered_at, onboarding_completed, grade_level } = rows[0];
// Check if trigger date has passed and onboarding not completed
// Note: grade_level check is not needed here as trigger date is only set when appropriate
// (grades 11-12 for K-12 schools, or immediately for colleges/universities)
const now = new Date();
const triggerDate = onboarding_triggered_at ? new Date(onboarding_triggered_at) : null;
const shouldTrigger =
triggerDate &&
triggerDate <= now &&
!onboarding_completed;
return res.json({
shouldTrigger: !!shouldTrigger,
triggerDate,
onboardingCompleted: !!onboarding_completed,
gradeLevel: grade_level
});
} catch (err) {
console.error('Error checking onboarding status:', err?.message || err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------
MARK ONBOARDING COMPLETED (MySQL)
------------------------------------------------------------------ */
app.post('/api/onboarding-completed', requireAuth, async (req, res) => {
const userId = req.userId;
try {
// Mark onboarding as completed for user's active enrollment
await pool.execute(`
UPDATE organization_students
SET onboarding_completed = TRUE,
onboarding_triggered = TRUE,
updated_at = NOW()
WHERE user_id = ?
AND enrollment_status NOT IN ('withdrawn', 'transferred', 'inactive')
`, [userId]);
return res.json({ message: 'Onboarding marked as completed' });
} catch (err) {
console.error('Error marking onboarding complete:', err?.message || err);
return res.status(500).json({ error: 'Internal server error' });
}
});
/* ------------------------------------------------------------------
SALARY_INFO REMAINS IN SQLITE
------------------------------------------------------------------ */
app.get('/api/areas', (req, res) => {
const { state } = req.query;
if (!state) {
return res.status(400).json({ error: 'State parameter is required' });
}
// Use env when present (Docker), fall back for local dev
const salaryDbPath =
process.env.SALARY_DB_PATH // ← preferred
|| process.env.SALARY_DB // ← legacy
|| '/app/salary_info.db'; // final fallback
const salaryDb = new sqlite3.Database(
salaryDbPath,
sqlite3.OPEN_READONLY,
(err) => {
if (err) {
console.error('DB connect error:', err.message);
return res
.status(500)
.json({ error: 'Failed to connect to database' });
}
}
);
const query =
`SELECT DISTINCT AREA_TITLE
FROM salary_data
WHERE PRIM_STATE = ?`;
salaryDb.all(query, [state], (err, rows) => {
if (err) {
console.error('Query error:', err.message);
return res
.status(500)
.json({ error: 'Failed to fetch areas' });
}
res.json({ areas: rows.map(r => r.AREA_TITLE) });
});
salaryDb.close();
});
/* ------------------------------------------------------------------
PREMIUM UPGRADE ENDPOINT
------------------------------------------------------------------ */
app.post('/api/activate-premium', requireAuth, async (req, res) => {
const profileId = req.userId;
try {
await pool.query(`
UPDATE user_profile
SET is_premium = 1,
is_pro_premium = 1
WHERE id = ?
`, [profileId]);
res.status(200).json({ message: 'Premium activated successfully' });
} catch (err) {
console.error('Error updating premium status:', err.message);
res.status(500).json({ error: 'Failed to activate premium' });
}
});
/* ------------------------------------------------------------------
STUDENT PRIVACY SETTINGS ENDPOINTS
------------------------------------------------------------------ */
// Get privacy settings for all organizations student belongs to
app.get('/api/privacy-settings', requireAuth, async (req, res) => {
const userId = req.userId;
try {
// Get all organizations this user is enrolled in
const [enrollments] = await pool.execute(`
SELECT os.organization_id, o.organization_name
FROM organization_students os
JOIN organizations o ON os.organization_id = o.id
WHERE os.user_id = ? AND os.enrollment_status = 'active'
`, [userId]);
if (enrollments.length === 0) {
return res.json({ organizations: [] });
}
// Get privacy settings for each organization
const settingsPromises = enrollments.map(async (enrollment) => {
const [settings] = await pool.execute(`
SELECT *
FROM student_privacy_settings
WHERE user_id = ? AND organization_id = ?
LIMIT 1
`, [userId, enrollment.organization_id]);
// Default to all false (private) if no settings exist
const hasConfigured = settings.length > 0;
const privacySettings = settings[0] || {
share_career_exploration: false,
share_interest_inventory: false,
share_career_profiles: false,
share_college_profiles: false,
share_financial_profile: false,
share_roadmap: false
};
return {
organization_id: enrollment.organization_id,
organization_name: enrollment.organization_name,
settings: privacySettings,
has_configured: hasConfigured
};
});
const allSettings = await Promise.all(settingsPromises);
res.json({ organizations: allSettings });
} catch (err) {
console.error('[privacy-settings GET] Error:', err.message);
res.status(500).json({ error: 'Failed to load privacy settings' });
}
});
// Update privacy settings for a specific organization
app.post('/api/privacy-settings', requireAuth, async (req, res) => {
const userId = req.userId;
const {
organization_id,
share_career_exploration,
share_interest_inventory,
share_career_profiles,
share_college_profiles,
share_financial_profile,
share_roadmap
} = req.body;
if (!organization_id) {
return res.status(400).json({ error: 'organization_id is required' });
}
try {
// Verify user is enrolled in this organization
const [enrollment] = await pool.execute(`
SELECT id FROM organization_students
WHERE user_id = ? AND organization_id = ? AND enrollment_status = 'active'
LIMIT 1
`, [userId, organization_id]);
if (!enrollment || enrollment.length === 0) {
return res.status(403).json({ error: 'Not enrolled in this organization' });
}
// Check if settings already exist
const [existing] = await pool.execute(`
SELECT id FROM student_privacy_settings
WHERE user_id = ? AND organization_id = ?
LIMIT 1
`, [userId, organization_id]);
if (existing && existing.length > 0) {
// Update existing settings
await pool.execute(`
UPDATE student_privacy_settings
SET
share_career_exploration = ?,
share_interest_inventory = ?,
share_career_profiles = ?,
share_college_profiles = ?,
share_financial_profile = ?,
share_roadmap = ?,
updated_at = UTC_TIMESTAMP()
WHERE user_id = ? AND organization_id = ?
`, [
share_career_exploration ?? false,
share_interest_inventory ?? false,
share_career_profiles ?? false,
share_college_profiles ?? false,
share_financial_profile ?? false,
share_roadmap ?? false,
userId,
organization_id
]);
} else {
// Insert new settings (default to false for privacy)
await pool.execute(`
INSERT INTO student_privacy_settings
(user_id, organization_id, share_career_exploration, share_interest_inventory,
share_career_profiles, share_college_profiles, share_financial_profile, share_roadmap,
updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, UTC_TIMESTAMP())
`, [
userId,
organization_id,
share_career_exploration ?? false,
share_interest_inventory ?? false,
share_career_profiles ?? false,
share_college_profiles ?? false,
share_financial_profile ?? false,
share_roadmap ?? false
]);
}
res.json({ message: 'Privacy settings updated successfully' });
} catch (err) {
console.error('[privacy-settings POST] Error:', err.message);
res.status(500).json({ error: 'Failed to update privacy settings' });
}
});
// ═══════════════════════════════════════════════════════════════════════════
// CAREER VIEW TRACKING
// ═══════════════════════════════════════════════════════════════════════════
app.post('/api/track-career-view', requireAuth, async (req, res) => {
try {
const userId = req.userId;
if (!userId) {
return res.status(401).json({ error: 'Unauthorized' });
}
const { career_soc_code, career_name } = req.body;
if (!career_soc_code || !career_name) {
return res.status(400).json({ error: 'career_soc_code and career_name are required' });
}
// Insert career view (fire and forget - don't need to wait for response)
pool.execute(
'INSERT INTO career_views (user_id, career_soc_code, career_name) VALUES (?, ?, ?)',
[userId, career_soc_code, career_name]
).catch(err => console.error('[track-career-view] Failed to insert:', err.message));
res.json({ success: true });
} catch (err) {
console.error('[track-career-view] Error:', err.message);
res.status(500).json({ error: 'Failed to track career view' });
}
});
app.use((err, req, res, _next) => {
if (res.headersSent) return;
const rid = req.headers['x-request-id'] || res.get('X-Request-ID') || getRequestId(req, res);
console.error(`[ref ${rid}]`, err?.message || err);
// map known cases if you have them; otherwise generic:
return res.status(500).json({ error: 'Server error', ref: rid });
});
/* ------------------------------------------------------------------
START SERVER
------------------------------------------------------------------ */
app.listen(PORT, () => {
console.log(`Server1 listening on port ${PORT}`);
});