469 lines
14 KiB
JavaScript
469 lines
14 KiB
JavaScript
|
|
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 app = express();
|
||
|
|
const PORT = process.env.PORT || 3000;
|
||
|
|
|
||
|
|
app.use(cors({
|
||
|
|
origin: true,
|
||
|
|
credentials: true
|
||
|
|
}));
|
||
|
|
app.use(express.json());
|
||
|
|
app.use(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('.'));
|
||
|
|
|
||
|
|
const db = new sqlite3.Database('./etf_trades.db', (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}`);
|
||
|
|
});
|