const express = require('express'); const session = require('express-session'); const sqlite3 = require('sqlite3').verbose(); const bcrypt = require('bcryptjs'); const path = require('path'); const PDFDocument = require('pdfkit'); const fs = require('fs'); const app = express(); const PORT = process.env.PORT || 3000; // Middleware app.use(express.urlencoded({ extended: true })); app.use(express.json()); app.use(express.static('public')); app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); // Session configuration app.use(session({ secret: 'fruehstueck-secret-key', resave: false, saveUninitialized: false, cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } })); // Database setup const db = new sqlite3.Database('./fruehstueck.db', (err) => { if (err) { console.error('Database connection error:', err); } else { console.log('Connected to SQLite database'); initializeDatabase(); } }); function initializeDatabase() { db.serialize(() => { // Users table (for staff/admin) db.run(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'employee' ) `); // Accounts table (for visitors) db.run(` CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, pin TEXT NOT NULL, balance REAL NOT NULL DEFAULT 0.0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `); // Items table (for fixed price items) db.run(` CREATE TABLE IF NOT EXISTS items ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, price REAL NOT NULL, description TEXT ) `); // Transactions table db.run(` CREATE TABLE IF NOT EXISTS transactions ( id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, type TEXT NOT NULL CHECK(type IN ('deposit', 'withdrawal')), amount REAL NOT NULL, description TEXT, item_id INTEGER, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, created_by INTEGER, FOREIGN KEY (account_id) REFERENCES accounts(id), FOREIGN KEY (item_id) REFERENCES items(id), FOREIGN KEY (created_by) REFERENCES users(id) ) `); // Insert default admin user if not exists db.get("SELECT * FROM users WHERE username = 'admin'", (err, row) => { if (err) { console.error('Error checking admin user:', err); return; } if (!row) { const hashedPassword = bcrypt.hashSync('admin123', 10); db.run("INSERT INTO users (username, password, role) VALUES (?, ?, ?)", ['admin', hashedPassword, 'admin'], (err) => { if (err) console.error('Error creating admin user:', err); else console.log('Default admin user created'); }); } }); // Insert some default items if none exist db.get("SELECT COUNT(*) as count FROM items", (err, row) => { if (err) { console.error('Error checking items:', err); return; } if (row.count === 0) { const items = [ { name: 'Kaffee', price: 1.50, description: 'Tasse Kaffee' }, { name: 'Tee', price: 1.20, description: 'Tasse Tee' }, { name: 'Brötchen', price: 0.80, description: 'Ein Brötchen' }, { name: 'Butter', price: 0.30, description: 'Portion Butter' }, { name: 'Marmelade', price: 0.30, description: 'Portion Marmelade' }, { name: 'Frischkäse', price: 0.50, description: 'Portion Frischkäse' }, { name: 'Honig', price: 0.40, description: 'Portion Honig' }, { name: 'Müsli', price: 2.00, description: 'Portion Müsli' }, { name: 'Joghurt', price: 1.00, description: 'Becher Joghurt' }, { name: 'Obst', price: 1.50, description: 'Portion Obst' } ]; items.forEach(item => { db.run("INSERT INTO items (name, price, description) VALUES (?, ?, ?)", [item.name, item.price, item.description]); }); console.log('Default items inserted'); } }); }); } // Authentication middleware function requireAuth(req, res, next) { if (req.session.userId) { db.get("SELECT * FROM users WHERE id = ?", [req.session.userId], (err, user) => { if (err || !user) { return res.redirect('/login'); } req.user = user; next(); }); } else { res.redirect('/login'); } } function requireAdmin(req, res, next) { if (req.user && req.user.role === 'admin') { return next(); } res.status(403).send('Zugriff verweigert: Nur für Administratoren'); } // Helper function to get account by PIN function getAccountByPin(pin, callback) { db.all("SELECT * FROM accounts", (err, accounts) => { if (err) { return callback(err, null); } const matchingAccount = accounts.find(acc => bcrypt.compareSync(pin, acc.pin)); callback(null, matchingAccount); }); } // Routes app.get('/', (req, res) => { if (req.session.userId) { res.redirect('/dashboard'); } else { res.redirect('/login'); } }); // Login routes app.get('/login', (req, res) => { res.render('login', { error: null, user: null }); }); app.post('/login', (req, res) => { const { username, password } = req.body; db.get("SELECT * FROM users WHERE username = ?", [username], (err, user) => { if (err || !user) { return res.render('login', { error: 'Benutzername oder Passwort falsch', user: null }); } if (bcrypt.compareSync(password, user.password)) { req.session.userId = user.id; req.session.username = user.username; req.session.role = user.role; res.redirect('/dashboard'); } else { res.render('login', { error: 'Benutzername oder Passwort falsch', user: null }); } }); }); app.get('/logout', (req, res) => { req.session.destroy(); res.redirect('/login'); }); // Dashboard app.get('/dashboard', requireAuth, (req, res) => { const isAdmin = req.user.role === 'admin'; // Get recent transactions db.all(` SELECT t.*, a.name as account_name, u.username as created_by_name, i.name as item_name FROM transactions t LEFT JOIN accounts a ON t.account_id = a.id LEFT JOIN users u ON t.created_by = u.id LEFT JOIN items i ON t.item_id = i.id ORDER BY t.created_at DESC LIMIT 20 `, (err, transactions) => { if (err) { console.error('Error fetching transactions:', err); transactions = []; } // Get accounts count and total balance db.get("SELECT COUNT(*) as count, SUM(balance) as total_balance FROM accounts", (err, stats) => { res.render('dashboard', { user: req.user, isAdmin: isAdmin, transactions: transactions, stats: stats }); }); }); }); // Account routes app.get('/accounts', requireAuth, (req, res) => { db.all("SELECT * FROM accounts ORDER BY name", (err, accounts) => { if (err) { console.error('Error fetching accounts:', err); accounts = []; } res.render('accounts', { user: req.user, accounts: accounts, isAdmin: req.user.role === 'admin' }); }); }); app.get('/accounts/new', requireAuth, (req, res) => { res.render('account_new', { user: req.user, error: null }); }); app.post('/accounts', requireAuth, (req, res) => { const { name, pin, initialBalance } = req.body; const balance = parseFloat(initialBalance) || 0; // Hash the PIN const hashedPin = bcrypt.hashSync(pin, 10); db.run("INSERT INTO accounts (name, pin, balance) VALUES (?, ?, ?)", [name, hashedPin, balance], function(err) { if (err) { console.error('Error creating account:', err); return res.render('account_new', { user: req.user, error: 'Fehler beim Anlegen des Kontos' }); } // Record initial deposit if balance > 0 if (balance > 0) { db.run("INSERT INTO transactions (account_id, type, amount, description, created_by) VALUES (?, ?, ?, ?, ?)", [this.lastID, 'deposit', balance, 'Anfangsguthaben', req.user.id]); } res.redirect('/accounts'); }); }); app.get('/accounts/:id', requireAuth, (req, res) => { const accountId = req.params.id; db.get("SELECT * FROM accounts WHERE id = ?", [accountId], (err, account) => { if (err || !account) { return res.status(404).send('Konto nicht gefunden'); } // Get transactions for this account db.all(` SELECT t.*, u.username as created_by_name, i.name as item_name FROM transactions t LEFT JOIN users u ON t.created_by = u.id LEFT JOIN items i ON t.item_id = i.id WHERE t.account_id = ? ORDER BY t.created_at DESC `, [accountId], (err, transactions) => { if (err) { console.error('Error fetching transactions:', err); transactions = []; } res.render('account_detail', { user: req.user, account: account, transactions: transactions, isAdmin: req.user.role === 'admin' }); }); }); }); app.get('/accounts/:id/edit', requireAuth, (req, res) => { const accountId = req.params.id; db.get("SELECT * FROM accounts WHERE id = ?", [accountId], (err, account) => { if (err || !account) { return res.status(404).send('Konto nicht gefunden'); } res.render('account_edit', { user: req.user, account: account, error: null }); }); }); app.post('/accounts/:id', requireAuth, (req, res) => { const accountId = req.params.id; const { name, pin } = req.body; let updateQuery = "UPDATE accounts SET name = ?"; const params = [name]; if (pin) { const hashedPin = bcrypt.hashSync(pin, 10); updateQuery += ", pin = ?"; params.push(hashedPin); } updateQuery += " WHERE id = ?"; params.push(accountId); db.run(updateQuery, params, (err) => { if (err) { console.error('Error updating account:', err); return res.redirect(`/accounts/${accountId}/edit`); } res.redirect(`/accounts/${accountId}`); }); }); // PDF Export route // PDF Export route - OPTIMIZED app.get('/accounts/:id/pdf', requireAuth, (req, res) => { const accountId = req.params.id; db.get("SELECT * FROM accounts WHERE id = ?", [accountId], (err, account) => { if (err || !account) { return res.status(404).send('Konto nicht gefunden'); } db.all(` SELECT t.*, u.username as created_by_name, i.name as item_name FROM transactions t LEFT JOIN users u ON t.created_by = u.id LEFT JOIN items i ON t.item_id = i.id WHERE t.account_id = ? ORDER BY t.created_at DESC `, [accountId], (err, transactions) => { if (err) { console.error('Error fetching transactions for PDF:', err); return res.status(500).send('Fehler beim Generieren des PDFs'); } const doc = new PDFDocument({ margin: 30, bufferPages: true, autoFirstPage: true, compress: true }); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="Kontoauszug_${account.name.replace(/\s+/g, '_')}_${new Date().toISOString().split('T')[0]}.pdf"`); doc.pipe(res); // Title - smaller doc.fontSize(16).text(`Kontoauszug für ${account.name}`, { align: 'center' }); doc.moveDown(0.5); // Account info - smaller fonts doc.fontSize(10); doc.text(`Kontostand: ${account.balance.toFixed(2)} €`); doc.text(`Konto erstellt: ${new Date(account.created_at).toLocaleDateString('de-DE')}`); doc.text(`Generiert am: ${new Date().toLocaleString('de-DE')}`); doc.text(`Generiert von: ${req.user.username}`); doc.moveDown(1.5); // Transaction history header doc.fontSize(12).text('Transaktionshistorie:', { underline: true }); doc.moveDown(1); // Table setup const tableTop = doc.y; const columnSpacing = 8; const pageWidth = doc.page.width - 60; const columns = [ { name: 'Datum', width: 100 }, { name: 'Typ', width: 60 }, { name: 'Betrag', width: 50 }, { name: 'Beschreibung', width: 120 }, { name: 'Artikel', width: 80 }, { name: 'Von', width: 70 } ]; const totalWidth = columns.reduce((sum, col) => sum + col.width, 0) + (columnSpacing * (columns.length - 1)); const scaleFactor = totalWidth > pageWidth ? pageWidth / totalWidth : 1; const scaledColumns = columns.map(col => ({ ...col, width: col.width * scaleFactor })); // Draw headers doc.font('Helvetica-Bold').fontSize(10); let x = 30; scaledColumns.forEach(col => { doc.text(col.name, x, tableTop, { width: col.width, align: 'left' }); x += col.width + columnSpacing; }); const tableEndX = x - columnSpacing; doc.moveTo(30, tableTop + 14).lineTo(tableEndX, tableTop + 14).stroke('#000000'); // Draw rows doc.font('Helvetica').fontSize(9); let y = tableTop + 22; const rowHeight = 16; transactions.forEach((tx, rowIndex) => { if (y > doc.page.height - 80) { doc.addPage(); y = 30; doc.font('Helvetica-Bold').fontSize(10); x = 30; scaledColumns.forEach(col => { doc.text(col.name, x, y, { width: col.width, align: 'left' }); x += col.width + columnSpacing; }); doc.moveTo(30, y + 14).lineTo(tableEndX, y + 14).stroke('#000000'); doc.font('Helvetica').fontSize(9); y += 22; } x = 30; const rowData = [ new Date(tx.created_at).toLocaleString('de-DE'), tx.type === 'deposit' ? 'Einzahlung' : 'Abbuchung', `${tx.amount.toFixed(2)} €`, (tx.description || '-').substring(0, 40), (tx.item_name || '-').substring(0, 25), (tx.created_by_name || 'System').substring(0, 20) ]; rowData.forEach((cell, i) => { doc.text(cell, x, y, { width: scaledColumns[i].width, align: 'left' }); x += scaledColumns[i].width + columnSpacing; }); y += rowHeight; if (rowIndex < transactions.length - 1) { doc.moveTo(30, y - 6).lineTo(tableEndX, y - 6).stroke('#e0e0e0'); } }); doc.moveDown(2); doc.fontSize(10); doc.text(`Gesamt: ${transactions.length} Transaktionen`, { align: 'right' }); doc.text(`Endstand: ${account.balance.toFixed(2)} €`, { align: 'right' }); doc.end(); }); }); }); // Deposit routes app.get('/accounts/:id/deposit', requireAuth, (req, res) => { const accountId = req.params.id; db.get("SELECT * FROM accounts WHERE id = ?", [accountId], (err, account) => { if (err || !account) { return res.status(404).send('Konto nicht gefunden'); } res.render('deposit', { user: req.user, account: account, error: null }); }); }); app.post('/accounts/:id/deposit', requireAuth, (req, res) => { const accountId = req.params.id; const { amount, description } = req.body; const depositAmount = parseFloat(amount); if (isNaN(depositAmount) || depositAmount <= 0) { return res.redirect(`/accounts/${accountId}/deposit?error=invalid_amount`); } // Update account balance db.run("UPDATE accounts SET balance = balance + ? WHERE id = ?", [depositAmount, accountId], function(err) { if (err) { console.error('Error updating balance:', err); return res.redirect(`/accounts/${accountId}/deposit?error=database_error`); } // Record transaction db.run("INSERT INTO transactions (account_id, type, amount, description, created_by) VALUES (?, ?, ?, ?, ?)", [accountId, 'deposit', depositAmount, description || 'Einzahlung', req.user.id], (err) => { if (err) console.error('Error recording transaction:', err); res.redirect(`/accounts/${accountId}`); }); }); }); // Withdrawal routes app.get('/accounts/:id/withdraw', requireAuth, (req, res) => { const accountId = req.params.id; db.get("SELECT * FROM accounts WHERE id = ?", [accountId], (err, account) => { if (err || !account) { return res.status(404).send('Konto nicht gefunden'); } // Get all items for selection db.all("SELECT * FROM items ORDER BY name", (err, items) => { if (err) { console.error('Error fetching items:', err); items = []; } res.render('withdraw', { user: req.user, account: account, items: items, error: req.query.error }); }); }); }); app.post('/accounts/:id/withdraw', requireAuth, (req, res) => { const accountId = req.params.id; const { amount, description, itemId, customAmount } = req.body; let withdrawalAmount; let finalDescription = description || ''; if (itemId) { // Fixed price item db.get("SELECT * FROM items WHERE id = ?", [itemId], (err, item) => { if (err || !item) { return res.redirect(`/accounts/${accountId}/withdraw?error=invalid_item`); } withdrawalAmount = item.price; finalDescription = item.name + (description ? ` - ${description}` : ''); processWithdrawal(); }); } else { // Custom amount withdrawalAmount = parseFloat(customAmount); if (isNaN(withdrawalAmount) || withdrawalAmount <= 0) { return res.redirect(`/accounts/${accountId}/withdraw?error=invalid_amount`); } processWithdrawal(); } function processWithdrawal() { // Check if account has sufficient balance db.get("SELECT balance FROM accounts WHERE id = ?", [accountId], (err, account) => { if (err || !account) { return res.redirect(`/accounts/${accountId}/withdraw?error=account_not_found`); } if (account.balance < withdrawalAmount) { return res.redirect(`/accounts/${accountId}/withdraw?error=insufficient_funds`); } // Update account balance db.run("UPDATE accounts SET balance = balance - ? WHERE id = ?", [withdrawalAmount, accountId], function(err) { if (err) { console.error('Error updating balance:', err); return res.redirect(`/accounts/${accountId}/withdraw?error=database_error`); } // Record transaction const transactionData = [ accountId, 'withdrawal', withdrawalAmount, finalDescription, req.user.id ]; if (itemId) { transactionData.push(itemId); db.run(` INSERT INTO transactions (account_id, type, amount, description, created_by, item_id) VALUES (?, ?, ?, ?, ?, ?) `, transactionData, (err) => { if (err) console.error('Error recording transaction:', err); res.redirect(`/accounts/${accountId}`); }); } else { db.run(` INSERT INTO transactions (account_id, type, amount, description, created_by) VALUES (?, ?, ?, ?, ?) `, transactionData, (err) => { if (err) console.error('Error recording transaction:', err); res.redirect(`/accounts/${accountId}`); }); } }); }); } }); // Items routes (admin only) app.get('/items', requireAuth, requireAdmin, (req, res) => { db.all("SELECT * FROM items ORDER BY name", (err, items) => { if (err) { console.error('Error fetching items:', err); items = []; } res.render('items', { user: req.user, items: items }); }); }); app.get('/items/new', requireAuth, requireAdmin, (req, res) => { res.render('item_new', { user: req.user, error: null, name: '', price: '', description: '' }); }); app.post('/items', requireAuth, requireAdmin, (req, res) => { const { name, price, description } = req.body; const itemPrice = parseFloat(price); if (isNaN(itemPrice) || itemPrice <= 0) { return res.render('item_new', { user: req.user, error: 'Ungültiger Preis', name: name, description: description }); } db.run("INSERT INTO items (name, price, description) VALUES (?, ?, ?)", [name, itemPrice, description], (err) => { if (err) { console.error('Error creating item:', err); return res.render('item_new', { user: req.user, error: 'Fehler beim Anlegen des Artikels', name: name, description: description }); } res.redirect('/items'); }); }); app.get('/items/:id/edit', requireAuth, requireAdmin, (req, res) => { const itemId = req.params.id; db.get("SELECT * FROM items WHERE id = ?", [itemId], (err, item) => { if (err || !item) { return res.status(404).send('Artikel nicht gefunden'); } res.render('item_edit', { user: req.user, item: item, error: null }); }); }); app.post('/items/:id', requireAuth, requireAdmin, (req, res) => { const itemId = req.params.id; const { name, price, description } = req.body; const itemPrice = parseFloat(price); if (isNaN(itemPrice) || itemPrice <= 0) { return res.redirect(`/items/${itemId}/edit?error=invalid_price`); } db.run("UPDATE items SET name = ?, price = ?, description = ? WHERE id = ?", [name, itemPrice, description, itemId], (err) => { if (err) { console.error('Error updating item:', err); return res.redirect(`/items/${itemId}/edit?error=database_error`); } res.redirect('/items'); }); }); app.post('/items/:id/delete', requireAuth, requireAdmin, (req, res) => { const itemId = req.params.id; db.run("DELETE FROM items WHERE id = ?", [itemId], (err) => { if (err) { console.error('Error deleting item:', err); return res.redirect('/items?error=delete_failed'); } res.redirect('/items'); }); }); // User management routes (admin only) app.get('/users', requireAuth, requireAdmin, (req, res) => { db.all("SELECT * FROM users ORDER BY username", (err, users) => { if (err) { console.error('Error fetching users:', err); users = []; } res.render('users', { user: req.user, users: users }); }); }); app.get('/users/new', requireAuth, requireAdmin, (req, res) => { res.render('user_new', { user: req.user, error: null, username: '', role: 'employee' }); }); app.post('/users', requireAuth, requireAdmin, (req, res) => { const { username, password, role } = req.body; const hashedPassword = bcrypt.hashSync(password, 10); db.run("INSERT INTO users (username, password, role) VALUES (?, ?, ?)", [username, hashedPassword, role], (err) => { if (err) { console.error('Error creating user:', err); return res.render('user_new', { user: req.user, error: 'Fehler beim Anlegen des Benutzers', username: username, role: role }); } res.redirect('/users'); }); }); app.post('/users/:id/delete', requireAuth, requireAdmin, (req, res) => { const userId = req.params.id; // Prevent deleting current user if (userId == req.user.id) { return res.redirect('/users?error=cannot_delete_self'); } db.run("DELETE FROM users WHERE id = ?", [userId], (err) => { if (err) { console.error('Error deleting user:', err); return res.redirect('/users?error=delete_failed'); } res.redirect('/users'); }); }); // PIN check route for visitors app.get('/pin-check', (req, res) => { res.render('pin_check', { error: null, success: false, account: null, transactions: null, user: null }); }); app.post('/pin-check', (req, res) => { const { pin } = req.body; if (!pin) { return res.render('pin_check', { error: 'Bitte geben Sie eine PIN ein', success: false, account: null, transactions: null, user: null }); } getAccountByPin(pin, (err, account) => { if (err || !account) { return res.render('pin_check', { error: 'Ungültige PIN', success: false, account: null, transactions: null, user: null }); } // Get last 10 transactions for this account db.all(` SELECT t.*, u.username as created_by_name, i.name as item_name FROM transactions t LEFT JOIN users u ON t.created_by = u.id LEFT JOIN items i ON t.item_id = i.id WHERE t.account_id = ? ORDER BY t.created_at DESC LIMIT 10 `, [account.id], (err, transactions) => { if (err) { console.error('Error fetching transactions for PIN check:', err); transactions = []; } res.render('pin_check', { error: null, success: true, account: account, transactions: transactions, user: null }); }); }); }); // API endpoint for PIN check (for potential mobile app integration) app.post('/api/pin-check', (req, res) => { const { pin } = req.body; if (!pin) { return res.json({ error: 'PIN ist erforderlich' }); } getAccountByPin(pin, (err, account) => { if (err || !account) { return res.json({ error: 'Ungültige PIN' }); } // Get last 10 transactions db.all(` SELECT t.*, u.username as created_by_name, i.name as item_name FROM transactions t LEFT JOIN users u ON t.created_by = u.id LEFT JOIN items i ON t.item_id = i.id WHERE t.account_id = ? ORDER BY t.created_at DESC LIMIT 10 `, [account.id], (err, transactions) => { if (err) { console.error('Error fetching transactions:', err); transactions = []; } res.json({ success: true, name: account.name, balance: account.balance, transactions: transactions.map(tx => ({ date: tx.created_at, type: tx.type, amount: tx.amount, description: tx.description, item: tx.item_name, by: tx.created_by_name })) }); }); }); }); // Start server app.listen(PORT, () => { console.log(`Server running on http://localhost:${PORT}`); }); // Handle database close on exit process.on('SIGINT', () => { db.close(); process.exit(); }); process.on('SIGTERM', () => { db.close(); process.exit(); });