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}`); });