c2a629e2c0
- Views umstrukturiert: einstellungen.ejs -> bewerbung.ejs, neues partials/head.ejs, header/footer/index angepasst - CSS umbenannt: style.css -> styles.css - server.js und public/js/main.js ueberarbeitet - Dockerfile auf schlankes Multi-Stage-Setup umgestellt; docker-compose.yml und .dockerignore entfernt - npm-Scripts docker:build/push/deploy ergaenzt - SQLite-DB und .idea aus Git entfernt und via .gitignore ignoriert Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
498 lines
16 KiB
JavaScript
498 lines
16 KiB
JavaScript
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, '"')
|
|
.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();
|
|
});
|