893 lines
27 KiB
JavaScript
Executable File
893 lines
27 KiB
JavaScript
Executable File
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
|
||
|
||
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.NODE_ENV?.trim() || 'development';
|
||
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
|
||
|
||
// 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;
|
||
|
||
/* ─── Allowed origins for CORS (comma-separated in env) ──────── */
|
||
const allowedOrigins = process.env.CORS_ALLOWED_ORIGINS
|
||
.split(',')
|
||
.map(o => o.trim())
|
||
.filter(Boolean);
|
||
|
||
|
||
app.disable('x-powered-by');
|
||
app.use(bodyParser.json());
|
||
app.use(express.json());
|
||
app.use(
|
||
helmet({
|
||
contentSecurityPolicy: false,
|
||
crossOriginEmbedderPolicy: false,
|
||
})
|
||
);
|
||
|
||
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 dynamic origin checking
|
||
app.use(
|
||
cors({
|
||
origin: (origin, callback) => {
|
||
if (!origin || allowedOrigins.includes(origin)) {
|
||
callback(null, true);
|
||
} else {
|
||
console.error('Blocked by CORS:', origin);
|
||
callback(new Error('Not allowed by CORS'));
|
||
}
|
||
},
|
||
methods: ['GET', 'POST', 'OPTIONS'],
|
||
allowedHeaders: [
|
||
'Authorization',
|
||
'Content-Type',
|
||
'Accept',
|
||
'Origin',
|
||
'X-Requested-With',
|
||
],
|
||
credentials: true,
|
||
})
|
||
);
|
||
|
||
// Handle preflight requests explicitly
|
||
app.options('*', (req, res) => {
|
||
res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '');
|
||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||
res.setHeader(
|
||
'Access-Control-Allow-Headers',
|
||
'Authorization, Content-Type, Accept, Origin, X-Requested-With'
|
||
);
|
||
res.status(200).end();
|
||
});
|
||
|
||
// Add HTTP headers for security
|
||
app.use((req, res, next) => {
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
res.setHeader('X-Frame-Options', 'DENY');
|
||
res.setHeader(
|
||
'Strict-Transport-Security',
|
||
'max-age=31536000; includeSubDomains'
|
||
);
|
||
res.setHeader('Content-Security-Policy', "default-src 'self';");
|
||
res.removeHeader('X-Powered-By');
|
||
next();
|
||
});
|
||
|
||
// Force Content-Type to application/json on all responses
|
||
app.use((req, res, next) => {
|
||
res.setHeader('Content-Type', 'application/json');
|
||
next();
|
||
});
|
||
|
||
const pwBurstLimiter = rateLimit({
|
||
windowMs: 30 * 1000, // 1 every 30s
|
||
max: 1,
|
||
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) {
|
||
// Update via join to the profile’s email
|
||
const sql = `
|
||
UPDATE user_auth ua
|
||
JOIN user_profile up ON up.id = ua.user_id
|
||
SET ua.hashed_password = ?
|
||
WHERE up.email = ?
|
||
LIMIT 1
|
||
`;
|
||
const [r] = await (pool.raw || pool).query(sql, [bcryptHash, email]);
|
||
return !!r?.affectedRows;
|
||
}
|
||
// ----- Password reset config (zero-config dev mode) -----
|
||
const RESET_CONFIG = {
|
||
// accept both spellings just in case
|
||
BASE_URL: process.env.APTIVA_API_BASE || process.env.APTIV_API_BASE || 'http://localhost:3000',
|
||
FROM: 'no-reply@aptivaai.com', // edit here if you want
|
||
TTL_MIN: 60, // edit here if you want
|
||
};
|
||
|
||
// --- 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');
|
||
}
|
||
|
||
|
||
// Change password (must be logged in)
|
||
app.post('/api/auth/password-change', pwBurstLimiter, async (req, res) => {
|
||
try {
|
||
const token = req.headers.authorization?.split(' ')[1];
|
||
if (!token) return res.status(401).json({ error: 'Auth required' });
|
||
|
||
let userId;
|
||
try {
|
||
({ id: userId } = jwt.verify(token, JWT_SECRET));
|
||
} catch {
|
||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||
}
|
||
|
||
const { currentPassword, newPassword } = req.body || {};
|
||
if (
|
||
typeof currentPassword !== 'string' ||
|
||
typeof newPassword !== 'string' ||
|
||
newPassword.length < 8
|
||
) {
|
||
return res.status(400).json({ error: 'New password must be at least 8 characters' });
|
||
}
|
||
if (newPassword === currentPassword) {
|
||
return res.status(400).json({ error: 'New password must be different' });
|
||
}
|
||
|
||
// fetch existing hash
|
||
const [rows] = await (pool.raw || pool).query(
|
||
'SELECT hashed_password FROM user_auth WHERE user_id = ? LIMIT 1',
|
||
[userId]
|
||
);
|
||
const existing = rows?.[0];
|
||
if (!existing) return res.status(404).json({ error: 'Account not found' });
|
||
|
||
// verify old password
|
||
const ok = await bcrypt.compare(currentPassword, existing.hashed_password);
|
||
if (!ok) return res.status(403).json({ error: 'Current password is incorrect' });
|
||
|
||
// write new hash
|
||
const newHash = await bcrypt.hash(newPassword, 10);
|
||
await (pool.raw || pool).query(
|
||
'UPDATE user_auth SET hashed_password = ? WHERE user_id = ? LIMIT 1',
|
||
[newHash, 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', pwBurstLimiter, 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 q = `
|
||
SELECT ua.user_id
|
||
FROM user_auth ua
|
||
JOIN user_profile up ON up.id = ua.user_id
|
||
WHERE up.email = ?
|
||
LIMIT 1
|
||
`;
|
||
const [rows] = await (pool.raw || pool).query(q, [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 didn’t 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', pwBurstLimiter, async (req, res) => {
|
||
try {
|
||
const { token, password } = req.body || {};
|
||
const t = String(token || '');
|
||
const p = String(password || '');
|
||
|
||
if (!t || p.length < 8) {
|
||
return res.status(400).json({ error: 'Invalid request' });
|
||
}
|
||
|
||
const tokenHash = crypto.createHash('sha256').update(t).digest('hex');
|
||
const now = Date.now();
|
||
|
||
const [rows] = await (pool.raw || pool).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 row = rows?.[0];
|
||
if (!row) return res.status(400).json({ error: 'Invalid or expired token' });
|
||
|
||
const hashed = await bcrypt.hash(p, 10); // matches your registration cost
|
||
|
||
const ok = await setPasswordByEmail(row.email, hashed);
|
||
if (!ok) return res.status(500).json({ error: 'Password update failed' });
|
||
|
||
await (pool.raw || pool).query(
|
||
`UPDATE password_resets SET used_at = ? WHERE id = ? LIMIT 1`,
|
||
[now, row.id]
|
||
);
|
||
|
||
return res.status(200).json({ ok: true });
|
||
} catch (e) {
|
||
console.error('[password-reset/confirm]', e?.message || e);
|
||
return res.status(500).json({ error: 'Server error' });
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
USER REGISTRATION (MySQL)
|
||
------------------------------------------------------------------ */
|
||
/**
|
||
* POST /api/register
|
||
* Body:
|
||
* username, password, firstname, lastname, email, zipcode, state, area, career_situation
|
||
*/
|
||
app.post('/api/register', async (req, res) => {
|
||
const {
|
||
username, password, firstname, lastname, email,
|
||
zipcode, state, area, career_situation, phone_e164, sms_opt_in
|
||
} = req.body;
|
||
|
||
if (!username || !password || !firstname || !lastname || !email || !zipcode || !state || !area) {
|
||
return res.status(400).json({ error: 'Missing required fields.' });
|
||
}
|
||
|
||
if (sms_opt_in && !/^\+\d{8,15}$/.test(phone_e164 || '')) {
|
||
return res.status(400).json({ error: 'Phone must be +E.164 format.' });
|
||
}
|
||
|
||
try {
|
||
const hashedPassword = await bcrypt.hash(password, 10);
|
||
|
||
const profileQuery = `
|
||
INSERT INTO user_profile
|
||
(username, firstname, lastname, email, zipcode, state, area, career_situation, phone_e164, sms_opt_in)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`;
|
||
const [resultProfile] = await pool.query(profileQuery, [
|
||
username, firstname, lastname, email, zipcode, state, area,
|
||
career_situation, phone_e164 || null, sms_opt_in ? 1 : 0
|
||
]);
|
||
|
||
const newProfileId = resultProfile.insertId;
|
||
|
||
const authQuery = `
|
||
INSERT INTO user_auth (user_id, username, hashed_password)
|
||
VALUES (?, ?, ?)
|
||
`;
|
||
await pool.query(authQuery, [newProfileId, username, hashedPassword]);
|
||
|
||
const token = jwt.sign({ id: newProfileId }, JWT_SECRET, { expiresIn: '2h' });
|
||
|
||
const userPayload = {
|
||
username, firstname, lastname, email, zipcode, state, area,
|
||
career_situation, phone_e164, sms_opt_in: !!sms_opt_in
|
||
};
|
||
|
||
return res.status(201).json({
|
||
message: 'User registered successfully',
|
||
profileId: newProfileId,
|
||
token,
|
||
user: userPayload
|
||
});
|
||
} catch (err) {
|
||
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
|
||
*/
|
||
app.post('/api/signin', async (req, res) => {
|
||
const { username, password } = req.body;
|
||
if (!username || !password) {
|
||
return res
|
||
.status(400)
|
||
.json({ error: 'Both username and password are required' });
|
||
}
|
||
|
||
// SELECT only the columns you actually have:
|
||
// 'ua.id' is user_auth's primary key,
|
||
// 'ua.user_id' references user_profile.id,
|
||
// and we alias user_profile.id as profileId for clarity.
|
||
const query = `
|
||
SELECT
|
||
ua.id AS authId,
|
||
ua.user_id AS userProfileId,
|
||
ua.hashed_password,
|
||
up.firstname,
|
||
up.lastname,
|
||
up.email,
|
||
up.zipcode,
|
||
up.state,
|
||
up.area,
|
||
up.career_situation
|
||
FROM user_auth ua
|
||
LEFT JOIN user_profile up ON ua.user_id = up.id
|
||
WHERE ua.username = ?
|
||
`;
|
||
|
||
try {
|
||
const [results] = await pool.query(query, [username]);
|
||
|
||
if (!results || results.length === 0) {
|
||
return res.status(401).json({ error: 'Invalid username or password' });
|
||
}
|
||
|
||
const row = results[0];
|
||
|
||
// Compare password with bcrypt
|
||
const isMatch = await bcrypt.compare(password, row.hashed_password);
|
||
if (!isMatch) {
|
||
return res.status(401).json({ error: 'Invalid username or password' });
|
||
}
|
||
|
||
// Return user info + token
|
||
// 'authId' is user_auth's PK, but typically you won't need it on the client
|
||
// 'row.userProfileId' is the actual user_profile.id
|
||
const [profileRows] = await pool.query(
|
||
'SELECT firstname, lastname, email, zipcode, state, area, career_situation \
|
||
FROM user_profile WHERE id = ?',
|
||
[row.userProfileId]
|
||
);
|
||
const profile = profileRows[0]; // ← already decrypted
|
||
|
||
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, { expiresIn: '2h' });
|
||
|
||
res.status(200).json({
|
||
message: 'Login successful',
|
||
token,
|
||
id: row.userProfileId,
|
||
user: profile
|
||
});
|
||
} catch (err) {
|
||
console.error('Error querying user_auth:', err.message);
|
||
return res
|
||
.status(500)
|
||
.json({ error: 'Failed to query user authentication data' });
|
||
}
|
||
});
|
||
|
||
|
||
|
||
/* ------------------------------------------------------------------
|
||
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)
|
||
------------------------------------------------------------------ */
|
||
/**
|
||
* POST /api/user-profile
|
||
* Headers: { Authorization: Bearer <token> }
|
||
* Body: { userName, firstName, lastName, email, zipCode, state, area, ... }
|
||
*
|
||
* If user_profile row exists (id = token.id), update
|
||
* else insert
|
||
*/
|
||
app.post('/api/user-profile', async (req, res) => {
|
||
const token = req.headers.authorization?.split(' ')[1];
|
||
if (!token) {
|
||
return res.status(401).json({ error: 'Authorization token is required' });
|
||
}
|
||
|
||
let profileId;
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET);
|
||
profileId = decoded.id;
|
||
} catch (error) {
|
||
console.error('JWT verification failed:', error);
|
||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||
}
|
||
|
||
const {
|
||
userName,
|
||
firstName,
|
||
lastName,
|
||
email,
|
||
zipCode,
|
||
state,
|
||
area,
|
||
careerSituation,
|
||
interest_inventory_answers,
|
||
riasec: riasec_scores,
|
||
career_priorities,
|
||
career_list,
|
||
phone_e164,
|
||
sms_opt_in
|
||
} = req.body;
|
||
|
||
try {
|
||
const [results] = await pool.query(
|
||
`SELECT * FROM user_profile WHERE id = ?`,
|
||
[profileId]
|
||
);
|
||
const existingRow = results.length > 0 ? results[0] : null;
|
||
|
||
if (
|
||
!existingRow &&
|
||
(!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
|
||
: existingRow?.interest_inventory_answers || null;
|
||
|
||
const finalCareerPriorities =
|
||
career_priorities !== undefined
|
||
? career_priorities
|
||
: existingRow?.career_priorities || null;
|
||
|
||
const finalCareerList =
|
||
career_list !== undefined
|
||
? career_list
|
||
: existingRow?.career_list || null;
|
||
|
||
const finalUserName =
|
||
userName !== undefined ? userName : existingRow?.username || null;
|
||
|
||
const finalRiasec = riasec_scores
|
||
? JSON.stringify(riasec_scores)
|
||
: existingRow?.riasec_scores || null;
|
||
|
||
if (existingRow) {
|
||
const updateQuery = `
|
||
UPDATE user_profile
|
||
SET
|
||
username = ?,
|
||
firstname = ?,
|
||
lastname = ?,
|
||
email = ?,
|
||
zipcode = ?,
|
||
state = ?,
|
||
area = ?,
|
||
career_situation = ?,
|
||
interest_inventory_answers = ?,
|
||
riasec_scores = ?,
|
||
career_priorities = ?,
|
||
career_list = ?
|
||
WHERE id = ?
|
||
`;
|
||
const params = [
|
||
finalUserName,
|
||
firstName || existingRow.firstname,
|
||
lastName || existingRow.lastname,
|
||
email || existingRow.email,
|
||
zipCode || existingRow.zipcode,
|
||
state || existingRow.state,
|
||
area || existingRow.area,
|
||
careerSituation || existingRow.career_situation,
|
||
finalAnswers,
|
||
finalRiasec,
|
||
finalCareerPriorities,
|
||
finalCareerList,
|
||
finalCareerList,
|
||
phone_e164 ?? existingRow.phone_e164 ?? null,
|
||
typeof sms_opt_in === 'boolean' ? (sms_opt_in ? 1 : 0) : existingRow.sms_opt_in ?? 0,
|
||
profileId
|
||
];
|
||
|
||
await pool.query(updateQuery, params);
|
||
return res
|
||
.status(200)
|
||
.json({ message: 'User profile updated successfully' });
|
||
} else {
|
||
const insertQuery = `
|
||
INSERT INTO user_profile
|
||
(id, username, firstname, lastname, email, zipcode, state, area,
|
||
career_situation, interest_inventory_answers, riasec_scores,
|
||
career_priorities, career_list)
|
||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||
`;
|
||
const params = [
|
||
profileId,
|
||
finalUserName,
|
||
firstName,
|
||
lastName,
|
||
email,
|
||
zipCode,
|
||
state,
|
||
area,
|
||
careerSituation || null,
|
||
finalAnswers,
|
||
finalRiasec,
|
||
finalCareerPriorities,
|
||
finalCareerList,
|
||
phone_e164 || null,
|
||
sms_opt_in ? 1 : 0
|
||
];
|
||
|
||
await pool.query(insertQuery, params);
|
||
return res
|
||
.status(201)
|
||
.json({ message: 'User profile created successfully', id: profileId });
|
||
}
|
||
} catch (err) {
|
||
console.error('Error upserting user profile:', err.message);
|
||
return res.status(500).json({ error: 'Internal server error' });
|
||
}
|
||
});
|
||
|
||
|
||
/* ------------------------------------------------------------------
|
||
FETCH USER PROFILE (MySQL)
|
||
------------------------------------------------------------------ */
|
||
app.get('/api/user-profile', async (req, res) => {
|
||
const token = req.headers.authorization?.split(' ')[1];
|
||
if (!token) return res.status(401).json({ error: 'Authorization token is required' });
|
||
|
||
let profileId;
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET);
|
||
profileId = decoded.id;
|
||
} catch (error) {
|
||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||
}
|
||
|
||
try {
|
||
const [results] = await pool.query('SELECT * FROM user_profile WHERE id = ?', [profileId]);
|
||
if (!results || results.length === 0) {
|
||
return res.status(404).json({ error: 'User profile not found' });
|
||
}
|
||
res.status(200).json(results[0]);
|
||
} catch (err) {
|
||
console.error('Error fetching user profile:', err.message);
|
||
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', async (req, res) => {
|
||
const token = req.headers.authorization?.split(' ')[1];
|
||
if (!token) return res.status(401).json({ error: 'Authorization token is required' });
|
||
|
||
let profileId;
|
||
try {
|
||
const decoded = jwt.verify(token, JWT_SECRET);
|
||
profileId = decoded.id;
|
||
} catch (error) {
|
||
return res.status(401).json({ error: 'Invalid or expired token' });
|
||
}
|
||
|
||
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' });
|
||
}
|
||
});
|
||
|
||
|
||
/* ------------------------------------------------------------------
|
||
START SERVER
|
||
------------------------------------------------------------------ */
|
||
app.listen(PORT, () => {
|
||
console.log(`Server running on http://localhost:${PORT}`);
|
||
});
|