556 lines
17 KiB
JavaScript
Executable File
556 lines
17 KiB
JavaScript
Executable File
import express from 'express';
|
||
import axios from 'axios';
|
||
import cors from 'cors';
|
||
import helmet from 'helmet';
|
||
import dotenv from 'dotenv';
|
||
import { fileURLToPath } from 'url';
|
||
import fs from 'fs';
|
||
import path from 'path';
|
||
import bodyParser from 'body-parser';
|
||
import bcrypt from 'bcrypt';
|
||
import jwt from 'jsonwebtoken'; // For token-based authentication
|
||
|
||
import pool from './config/mysqlPool.js'; // adjust path if needed
|
||
|
||
import sqlite3 from 'sqlite3';
|
||
|
||
const __filename = fileURLToPath(import.meta.url);
|
||
const __dirname = path.dirname(__filename);
|
||
|
||
const rootPath = path.resolve(__dirname, '..'); // Up one level
|
||
const env = process.env.NODE_ENV?.trim() || 'development';
|
||
const stage = env === 'staging' ? 'development' : env;
|
||
const envPath = path.resolve(rootPath, `.env.${env}`);
|
||
dotenv.config({ path: envPath }); // Load .env file
|
||
|
||
// Grab secrets and config from ENV
|
||
const JWT_SECRET = process.env.JWT_SECRET;
|
||
const DB_HOST = process.env.DB_HOST || '127.0.0.1';
|
||
const DB_PORT = process.env.DB_PORT ? parseInt(process.env.DB_PORT) : 3306;
|
||
const DB_USER = process.env.DB_USER || 'sqluser';
|
||
const DB_PASSWORD = process.env.DB_PASSWORD || '';
|
||
const DB_NAME = process.env.DB_NAME || 'user_profile_db';
|
||
|
||
if (!JWT_SECRET) {
|
||
console.error('FATAL: JWT_SECRET missing – aborting startup');
|
||
process.exit(1); // container exits, Docker marks it unhealthy
|
||
}
|
||
|
||
// Test a quick query (optional)
|
||
try {
|
||
const [rows] = await pool.query('SELECT 1');
|
||
console.log('Connected to MySQL user_profile_db');
|
||
} catch (err) {
|
||
console.error('Error connecting to MySQL user_profile_db:', err.message);
|
||
}
|
||
|
||
|
||
const app = express();
|
||
const PORT = process.env.SERVER1_PORT || 5000;
|
||
|
||
/* ─── Require critical env vars ───────────────────────────────── */
|
||
if (!process.env.CORS_ALLOWED_ORIGINS) {
|
||
console.error('FATAL CORS_ALLOWED_ORIGINS is not set'); // eslint-disable-line
|
||
process.exit(1);
|
||
}
|
||
|
||
/* ─── 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,
|
||
})
|
||
);
|
||
|
||
app.get('/healthz', (req, res) => res.sendStatus(204)); // 204 No Content
|
||
|
||
// 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();
|
||
});
|
||
|
||
/* ------------------------------------------------------------------
|
||
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' });
|
||
}
|
||
|
||
// IMPORTANT: Use 'row.userProfileId' (from user_profile.id) in the token
|
||
// so your '/api/user-profile' can decode it and do SELECT * FROM user_profile WHERE id=?
|
||
const token = jwt.sign({ id: row.userProfileId }, JWT_SECRET, {
|
||
expiresIn: '2h',
|
||
});
|
||
|
||
// 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
|
||
res.status(200).json({
|
||
message: 'Login successful',
|
||
token,
|
||
id: row.userProfileId, // This is user_profile.id (important if your frontend needs it)
|
||
user: {
|
||
firstname: row.firstname,
|
||
lastname: row.lastname,
|
||
email: row.email,
|
||
zipcode: row.zipcode,
|
||
state: row.state,
|
||
area: row.area,
|
||
career_situation: row.career_situation,
|
||
},
|
||
});
|
||
} 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,
|
||
} = 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,
|
||
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,
|
||
];
|
||
|
||
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}`);
|
||
});
|