const express = require('express'); const sqlite3 = require('sqlite3').verbose(); const path = require('path'); const fs = require('fs'); const app = express(); const PORT = process.env.PORT || 3000; // Shared option lists (used in multiple views) const ART_OPTIONS = [ 'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite', 'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges' ]; const STATUS_OPTIONS = [ 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch', 'Absage', 'Einstellung', 'Keine Rückmeldung' ]; // Middleware app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(express.static(path.join(__dirname, 'public'))); // Set EJS as template engine app.set('view engine', 'ejs'); app.set('views', path.join(__dirname, 'views')); // Ensure data directory exists const dataDir = path.join(__dirname, 'data'); if (!fs.existsSync(dataDir)) { fs.mkdirSync(dataDir, { recursive: true }); } // Database setup const dbPath = path.join(dataDir, 'bewerbungen.db'); const db = new sqlite3.Database(dbPath); // Sanitize input to prevent XSS function sanitizeInput(input) { if (typeof input !== 'string') return input; return input .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } // Promise wrapper for db operations function dbGet(sql, params = []) { return new Promise((resolve, reject) => { db.get(sql, params, (err, result) => { if (err) reject(err); else resolve(result); }); }); } function dbAll(sql, params = []) { return new Promise((resolve, reject) => { db.all(sql, params, (err, results) => { if (err) reject(err); else resolve(results); }); }); } function dbRun(sql, params = []) { return new Promise((resolve, reject) => { db.run(sql, params, function(err) { if (err) reject(err); else resolve({ lastID: this.lastID, changes: this.changes }); }); }); } // Recompute an application's current status from its latest timeline entry async function syncCurrentStatus(bewerbungId) { const latest = await dbGet( 'SELECT status FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) DESC, id DESC LIMIT 1', [bewerbungId] ); await dbRun( 'UPDATE bewerbungen SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [latest ? latest.status : '', bewerbungId] ); } // Attach the status timeline to each application (single query, grouped in JS) async function attachVerlauf(applications) { if (!applications.length) return applications; const all = await dbAll('SELECT * FROM status_verlauf ORDER BY date(datum) ASC, id ASC'); const byApp = {}; all.forEach((v) => { (byApp[v.bewerbung_id] = byApp[v.bewerbung_id] || []).push(v); }); applications.forEach((a) => { a.verlauf = byApp[a.id] || []; }); return applications; } // Initialize database - create tables and default settings in one operation function initializeDatabase() { return new Promise((resolve, reject) => { db.serialize(() => { db.run('PRAGMA foreign_keys = ON'); // Create tables db.run(` CREATE TABLE IF NOT EXISTS bewerbungen ( id INTEGER PRIMARY KEY AUTOINCREMENT, datum DATE NOT NULL, firma TEXT NOT NULL, stelle TEXT NOT NULL, art TEXT, status TEXT, notizen TEXT, interne_notizen TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ) `, (err) => { if (err) return reject(err); // Migration: add interne_notizen to pre-existing databases (ignore "duplicate column") db.run('ALTER TABLE bewerbungen ADD COLUMN interne_notizen TEXT', () => { // Chronological status changes, each with an optional comment db.run(` CREATE TABLE IF NOT EXISTS status_verlauf ( id INTEGER PRIMARY KEY AUTOINCREMENT, bewerbung_id INTEGER NOT NULL, datum DATE NOT NULL, status TEXT NOT NULL, kommentar TEXT, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE CASCADE ) `, (err) => { if (err) return reject(err); db.run(` CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY CHECK (id = 1), name TEXT, adresse TEXT, kundennummer TEXT ) `, (err) => { if (err) return reject(err); // Insert default settings if not exists db.get('SELECT COUNT(*) as count FROM settings WHERE id = 1', (err, result) => { if (err) return reject(err); if (result && result.count === 0) { db.run( 'INSERT INTO settings (id, name, adresse, kundennummer) VALUES (1, ?, ?, ?)', ['Max Mustermann', 'Musterstraße 1, 12345 Musterstadt', ''], (err) => { if (err) return reject(err); resolve(); } ); } else { resolve(); } }); }); }); }); }); }); }); } // Initialize and start server initializeDatabase().then(() => { console.log('Database initialized successfully'); // Routes app.get('/', async (req, res) => { try { const { month, year } = req.query; let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC, created_at DESC'; const params = []; if (month && year) { query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; params.push(month.padStart(2, '0'), year); } else if (year) { query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC, created_at DESC'; params.push(year); } const applications = await dbAll(query, params); await attachVerlauf(applications); const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); // Get statistics const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen'); const byArt = await dbAll(` SELECT art, COUNT(*) as count FROM bewerbungen WHERE art IS NOT NULL AND art != '' GROUP BY art ORDER BY count DESC `); const byStatus = await dbAll(` SELECT status, COUNT(*) as count FROM bewerbungen WHERE status IS NOT NULL AND status != '' GROUP BY status ORDER BY count DESC `); // Get available months/years for filter const availableMonths = await dbAll(` SELECT DISTINCT strftime("%Y-%m", datum) as yearmonth, strftime("%m", datum) as month, strftime("%Y", datum) as year FROM bewerbungen ORDER BY datum DESC `); res.render('index', { applications, settings, statistics: { total: totalCount ? totalCount.count : 0, byArt, byStatus }, availableMonths, currentFilter: { month, year }, artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS }); } catch (error) { console.error('Error:', error); res.status(500).send('Serverfehler'); } }); // Get single application app.get('/api/bewerbungen/:id', async (req, res) => { try { const { id } = req.params; const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); if (!application) { return res.status(404).json({ error: 'Bewerbung nicht gefunden' }); } res.json(application); } catch (error) { console.error('Error getting application:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Get settings app.get('/api/settings', async (req, res) => { try { const settings = await dbGet('SELECT * FROM settings WHERE id = 1'); res.json(settings); } catch (error) { console.error('Error getting settings:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Save settings app.post('/api/settings', async (req, res) => { try { const { name, adresse, kundennummer } = req.body; await dbRun( 'UPDATE settings SET name = ?, adresse = ?, kundennummer = ? WHERE id = 1', [sanitizeInput(name), sanitizeInput(adresse), sanitizeInput(kundennummer)] ); res.json({ success: true }); } catch (error) { console.error('Error saving settings:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Create application app.post('/api/bewerbungen', async (req, res) => { try { const { datum, firma, stelle, art, status, notizen, interne_notizen, kommentar } = req.body; const result = await dbRun( 'INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen, interne_notizen) VALUES (?, ?, ?, ?, ?, ?, ?)', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), sanitizeInput(interne_notizen)] ); // Record the initial status as the first timeline entry if (status && status.trim()) { await dbRun( 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', [result.lastID, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] ); } const newApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [result.lastID]); res.json({ success: true, application: newApplication }); } catch (error) { console.error('Error creating application:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Update application app.put('/api/bewerbungen/:id', async (req, res) => { try { const { id } = req.params; const { datum, firma, stelle, art, status, notizen } = req.body; await dbRun( 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, status = ?, notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(status), sanitizeInput(notizen), id] ); const updatedApplication = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); res.json({ success: true, application: updatedApplication }); } catch (error) { console.error('Error updating application:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Delete application app.delete('/api/bewerbungen/:id', async (req, res) => { try { const { id } = req.params; await dbRun('DELETE FROM status_verlauf WHERE bewerbung_id = ?', [id]); await dbRun('DELETE FROM bewerbungen WHERE id = ?', [id]); res.json({ success: true }); } catch (error) { console.error('Error deleting application:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // Applications for PDF export (optionally filtered), including the status timeline app.get('/api/export', async (req, res) => { try { const { month, year } = req.query; let query = 'SELECT * FROM bewerbungen ORDER BY datum DESC'; const params = []; if (month && year) { query = 'SELECT * FROM bewerbungen WHERE strftime("%m", datum) = ? AND strftime("%Y", datum) = ? ORDER BY datum DESC'; params.push(month.padStart(2, '0'), year); } else if (year) { query = 'SELECT * FROM bewerbungen WHERE strftime("%Y", datum) = ? ORDER BY datum DESC'; params.push(year); } const applications = await dbAll(query, params); await attachVerlauf(applications); // Internal notes must never reach the PDF/export applications.forEach((a) => { delete a.interne_notizen; }); res.json(applications); } catch (error) { console.error('Error exporting applications:', error); res.status(500).json({ error: 'Serverfehler' }); } }); // ----- Dedicated edit page + status-timeline management ----- // Edit page for a single application app.get('/bewerbung/:id', async (req, res) => { try { const { id } = req.params; const application = await dbGet('SELECT * FROM bewerbungen WHERE id = ?', [id]); if (!application) return res.status(404).send('Bewerbung nicht gefunden'); const verlauf = await dbAll( 'SELECT * FROM status_verlauf WHERE bewerbung_id = ? ORDER BY date(datum) ASC, id ASC', [id] ); res.render('bewerbung', { application, verlauf, artOptions: ART_OPTIONS, statusOptions: STATUS_OPTIONS, hideSettings: true }); } catch (error) { console.error('Error loading edit page:', error); res.status(500).send('Serverfehler'); } }); // Update application core data (status is managed via the timeline) app.post('/bewerbung/:id', async (req, res) => { try { const { id } = req.params; const { datum, firma, stelle, art, notizen, interne_notizen } = req.body; await dbRun( 'UPDATE bewerbungen SET datum = ?, firma = ?, stelle = ?, art = ?, notizen = ?, interne_notizen = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?', [datum, sanitizeInput(firma), sanitizeInput(stelle), sanitizeInput(art), sanitizeInput(notizen), sanitizeInput(interne_notizen), id] ); res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error updating application:', error); res.status(500).send('Serverfehler'); } }); // Add a timeline entry (status change with date + comment) app.post('/bewerbung/:id/verlauf', async (req, res) => { try { const { id } = req.params; const { datum, status, kommentar } = req.body; if (datum && status && status.trim()) { await dbRun( 'INSERT INTO status_verlauf (bewerbung_id, datum, status, kommentar) VALUES (?, ?, ?, ?)', [id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')] ); await syncCurrentStatus(id); } res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error adding timeline entry:', error); res.status(500).send('Serverfehler'); } }); // Update a timeline entry app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => { try { const { id, eintragId } = req.params; const { datum, status, kommentar } = req.body; if (datum && status && status.trim()) { await dbRun( 'UPDATE status_verlauf SET datum = ?, status = ?, kommentar = ? WHERE id = ? AND bewerbung_id = ?', [datum, sanitizeInput(status), sanitizeInput(kommentar || ''), eintragId, id] ); await syncCurrentStatus(id); } res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error updating timeline entry:', error); res.status(500).send('Serverfehler'); } }); // Delete a timeline entry app.post('/bewerbung/:id/verlauf/:eintragId/delete', async (req, res) => { try { const { id, eintragId } = req.params; await dbRun('DELETE FROM status_verlauf WHERE id = ? AND bewerbung_id = ?', [eintragId, id]); await syncCurrentStatus(id); res.redirect('/bewerbung/' + id); } catch (error) { console.error('Error deleting timeline entry:', error); res.status(500).send('Serverfehler'); } }); // Start server app.listen(PORT, () => { console.log(`Server läuft auf http://localhost:${PORT}`); }); // Handle 404 app.use((req, res) => { res.status(404).send('Seite nicht gefunden'); }); }).catch((err) => { console.error('Failed to initialize database:', err); process.exit(1); }); // Close database on exit process.on('SIGINT', () => { db.close(); process.exit(); }); process.on('SIGTERM', () => { db.close(); process.exit(); });