etf-trade-tracker/server.js

477 lines
14 KiB
JavaScript
Raw Normal View History

const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const path = require('path');
const session = require('express-session');
const bcrypt = require('bcrypt');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 3000;
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json());
app.use(session({
secret: process.env.SESSION_SECRET || 'etf-tracker-secret-key',
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // Set to true if using HTTPS
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
app.use(express.static('.'));
// Ensure data directory exists (for containers)
const dataDir = process.env.NODE_ENV === 'production' ? '/app/data' : './';
if (process.env.NODE_ENV === 'production' && !fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const dbPath = path.join(dataDir, 'etf_trades.db');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Error opening database:', err.message);
} else {
console.log('Connected to SQLite database');
initializeDatabase();
}
});
function initializeDatabase() {
// Create users table
const createUsersTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
email TEXT UNIQUE,
is_admin BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME
)
`;
// Create trades table with user_id
const createTradesTableSQL = `
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
etf_symbol TEXT NOT NULL,
trade_type TEXT NOT NULL CHECK (trade_type IN ('buy', 'sell')),
shares REAL NOT NULL CHECK (shares > 0),
price REAL NOT NULL CHECK (price > 0),
currency TEXT NOT NULL CHECK (currency IN ('EUR', 'USD')),
trade_datetime TEXT NOT NULL,
fees REAL DEFAULT 0 CHECK (fees >= 0),
notes TEXT,
total_value REAL NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users (id)
)
`;
db.run(createUsersTableSQL, (err) => {
if (err) {
console.error('Error creating users table:', err.message);
} else {
console.log('Users table ready');
createDefaultAdmin();
}
});
db.run(createTradesTableSQL, (err) => {
if (err) {
console.error('Error creating trades table:', err.message);
} else {
console.log('Trades table ready');
}
});
}
async function createDefaultAdmin() {
const adminUsername = 'admin';
const adminPassword = 'admin123';
db.get('SELECT id FROM users WHERE username = ?', [adminUsername], async (err, row) => {
if (err) {
console.error('Error checking for admin user:', err.message);
return;
}
if (!row) {
try {
const passwordHash = await bcrypt.hash(adminPassword, 10);
db.run(
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, ?)',
[adminUsername, passwordHash, 1],
function(err) {
if (err) {
console.error('Error creating admin user:', err.message);
} else {
console.log('Default admin user created (username: admin, password: admin123)');
}
}
);
} catch (error) {
console.error('Error hashing admin password:', error);
}
}
});
}
// Middleware to check if user is authenticated
function requireAuth(req, res, next) {
if (req.session && req.session.userId) {
next();
} else {
res.status(401).json({ error: 'Authentication required' });
}
}
// Middleware to check if user is admin
function requireAdmin(req, res, next) {
if (req.session && req.session.userId && req.session.isAdmin) {
next();
} else {
res.status(403).json({ error: 'Admin privileges required' });
}
}
// Authentication endpoints
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
db.get('SELECT * FROM users WHERE username = ?', [username], async (err, user) => {
if (err) {
console.error('Login error:', err.message);
return res.status(500).json({ error: 'Server error' });
}
if (!user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
try {
const isValid = await bcrypt.compare(password, user.password_hash);
if (!isValid) {
return res.status(401).json({ error: 'Invalid credentials' });
}
// Update last login
db.run('UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?', [user.id]);
// Set session
req.session.userId = user.id;
req.session.username = user.username;
req.session.isAdmin = user.is_admin;
res.json({
id: user.id,
username: user.username,
isAdmin: user.is_admin
});
} catch (error) {
console.error('Password comparison error:', error);
res.status(500).json({ error: 'Server error' });
}
});
});
app.post('/api/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({ error: 'Could not log out' });
}
res.json({ message: 'Logged out successfully' });
});
});
app.get('/api/me', requireAuth, (req, res) => {
res.json({
id: req.session.userId,
username: req.session.username,
isAdmin: req.session.isAdmin
});
});
// User management endpoints (admin only)
app.get('/api/admin/users', requireAdmin, (req, res) => {
const sql = 'SELECT id, username, email, is_admin, created_at, last_login FROM users ORDER BY created_at DESC';
db.all(sql, [], (err, rows) => {
if (err) {
console.error('Error fetching users:', err.message);
res.status(500).json({ error: 'Failed to fetch users' });
return;
}
res.json(rows);
});
});
app.post('/api/admin/users', requireAdmin, async (req, res) => {
const { username, password, email, isAdmin } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
try {
const passwordHash = await bcrypt.hash(password, 10);
const sql = 'INSERT INTO users (username, password_hash, email, is_admin) VALUES (?, ?, ?, ?)';
db.run(sql, [username, passwordHash, email || null, isAdmin ? 1 : 0], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
res.status(400).json({ error: 'Username or email already exists' });
} else {
console.error('Error creating user:', err.message);
res.status(500).json({ error: 'Failed to create user' });
}
return;
}
res.status(201).json({
id: this.lastID,
username,
email,
isAdmin: isAdmin || false
});
});
} catch (error) {
console.error('Password hashing error:', error);
res.status(500).json({ error: 'Server error' });
}
});
app.delete('/api/admin/users/:id', requireAdmin, (req, res) => {
const userId = req.params.id;
if (!userId || isNaN(userId)) {
return res.status(400).json({ error: 'Invalid user ID' });
}
// Prevent deleting yourself
if (parseInt(userId) === req.session.userId) {
return res.status(400).json({ error: 'Cannot delete your own account' });
}
db.run('DELETE FROM users WHERE id = ?', [userId], function(err) {
if (err) {
console.error('Error deleting user:', err.message);
res.status(500).json({ error: 'Failed to delete user' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json({ message: 'User deleted successfully' });
});
});
app.get('/api/trades', requireAuth, (req, res) => {
const sql = 'SELECT * FROM trades WHERE user_id = ? ORDER BY trade_datetime DESC, created_at DESC';
db.all(sql, [req.session.userId], (err, rows) => {
if (err) {
console.error('Error fetching trades:', err.message);
res.status(500).json({ error: 'Failed to fetch trades' });
return;
}
const trades = rows.map(row => ({
id: row.id.toString(),
etfSymbol: row.etf_symbol,
tradeType: row.trade_type,
shares: row.shares,
price: row.price,
currency: row.currency,
dateTime: row.trade_datetime,
fees: row.fees,
notes: row.notes,
totalValue: row.total_value,
timestamp: new Date(row.created_at).getTime()
}));
res.json(trades);
});
});
app.post('/api/trades', requireAuth, (req, res) => {
const {
etfSymbol,
tradeType,
shares,
price,
currency,
dateTime,
fees = 0,
notes = ''
} = req.body;
if (!etfSymbol || !tradeType || !shares || !price || !currency || !dateTime) {
return res.status(400).json({ error: 'Missing required fields' });
}
if (!['buy', 'sell'].includes(tradeType)) {
return res.status(400).json({ error: 'Invalid trade type' });
}
if (!['EUR', 'USD'].includes(currency)) {
return res.status(400).json({ error: 'Invalid currency' });
}
if (shares <= 0 || price <= 0 || fees < 0) {
return res.status(400).json({ error: 'Invalid numeric values' });
}
const totalValue = (shares * price) + fees;
const sql = `
INSERT INTO trades (user_id, etf_symbol, trade_type, shares, price, currency, trade_datetime, fees, notes, total_value)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
const params = [
req.session.userId,
etfSymbol.toUpperCase(),
tradeType,
shares,
price,
currency,
dateTime,
fees,
notes,
totalValue
];
db.run(sql, params, function(err) {
if (err) {
console.error('Error inserting trade:', err.message);
res.status(500).json({ error: 'Failed to add trade' });
return;
}
const newTrade = {
id: this.lastID.toString(),
etfSymbol: etfSymbol.toUpperCase(),
tradeType,
shares,
price,
currency,
dateTime,
fees,
notes,
totalValue,
timestamp: Date.now()
};
res.status(201).json(newTrade);
});
});
app.delete('/api/trades/:id', requireAuth, (req, res) => {
const tradeId = req.params.id;
if (!tradeId || isNaN(tradeId)) {
return res.status(400).json({ error: 'Invalid trade ID' });
}
const sql = 'DELETE FROM trades WHERE id = ? AND user_id = ?';
db.run(sql, [tradeId, req.session.userId], function(err) {
if (err) {
console.error('Error deleting trade:', err.message);
res.status(500).json({ error: 'Failed to delete trade' });
return;
}
if (this.changes === 0) {
res.status(404).json({ error: 'Trade not found' });
return;
}
res.json({ message: 'Trade deleted successfully' });
});
});
app.delete('/api/trades', requireAuth, (req, res) => {
const sql = 'DELETE FROM trades WHERE user_id = ?';
db.run(sql, [req.session.userId], function(err) {
if (err) {
console.error('Error clearing trades:', err.message);
res.status(500).json({ error: 'Failed to clear trades' });
return;
}
res.json({
message: 'All trades cleared successfully',
deletedCount: this.changes
});
});
});
app.get('/api/portfolio-summary', requireAuth, (req, res) => {
const sql = `
SELECT
etf_symbol,
SUM(CASE WHEN trade_type = 'buy' THEN shares ELSE -shares END) as total_shares,
SUM(CASE WHEN trade_type = 'buy' THEN total_value ELSE -total_value END) as total_invested,
COUNT(*) as trade_count
FROM trades
WHERE user_id = ?
GROUP BY etf_symbol
HAVING total_shares > 0
ORDER BY total_invested DESC
`;
db.all(sql, [req.session.userId], (err, rows) => {
if (err) {
console.error('Error fetching portfolio summary:', err.message);
res.status(500).json({ error: 'Failed to fetch portfolio summary' });
return;
}
const summary = rows.map(row => ({
symbol: row.etf_symbol,
totalShares: row.total_shares,
totalInvested: row.total_invested,
avgPrice: row.total_shares > 0 ? row.total_invested / row.total_shares : 0,
trades: row.trade_count
}));
res.json(summary);
});
});
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
process.on('SIGINT', () => {
console.log('\nShutting down server...');
db.close((err) => {
if (err) {
console.error('Error closing database:', err.message);
} else {
console.log('Database connection closed.');
}
process.exit(0);
});
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on http://0.0.0.0:${PORT}`);
});