Files
jobbi-bewerbung/server.js
T
thomas c65c9f1751 Initial commit
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-03 18:15:11 +02:00

235 lines
7.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
const express = require('express');
const path = require('path');
const fs = require('fs');
const methodOverride = require('method-override');
const Database = require('better-sqlite3');
const app = express();
const PORT = process.env.PORT || 3000;
// ── Database bootstrap ────────────────────────────────────────────────────────
const dataDir = path.join(__dirname, 'data');
if (!fs.existsSync(dataDir)) fs.mkdirSync(dataDir, { recursive: true });
const db = new Database(path.join(dataDir, 'bewerbungen.db'));
db.pragma('journal_mode = WAL');
db.exec(`
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,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
adresse TEXT,
kundennummer TEXT
);
INSERT OR IGNORE INTO settings (id) VALUES (1);
`);
// ── Constants ─────────────────────────────────────────────────────────────────
const ART_OPTIONEN = [
'E-Mail', 'Online-Portal', 'Indeed', 'StepStone', 'Firmenwebsite',
'Post', 'Initiativbewerbung', 'Arbeitsagentur', 'Sonstiges'
];
const STATUS_OPTIONEN = [
'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
'Absage', 'Einstellung', 'Keine Rückmeldung'
];
const MONATE = [
'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'
];
// ── Helpers ───────────────────────────────────────────────────────────────────
function sanitize(val, maxLen = 2000) {
if (val === null || val === undefined) return '';
return String(val).trim().slice(0, maxLen);
}
function safeJson(obj) {
return JSON.stringify(obj)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
}
function getSettings() {
return db.prepare('SELECT * FROM settings WHERE id = 1').get() || {};
}
// ── Middleware ────────────────────────────────────────────────────────────────
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(methodOverride('_method'));
app.use(express.static(path.join(__dirname, 'public')));
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, 'views'));
// ── Routes ────────────────────────────────────────────────────────────────────
// GET / Übersicht
app.get('/', (req, res) => {
const now = new Date();
const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || (now.getMonth() + 1)));
const jahr = parseInt(req.query.jahr) || now.getFullYear();
const monatStr = String(monat).padStart(2, '0');
const vonDatum = `${jahr}-${monatStr}-01`;
const bisDatum = `${jahr}-${monatStr}-31`;
const bewerbungen = db.prepare(`
SELECT * FROM bewerbungen
WHERE datum BETWEEN ? AND ?
ORDER BY datum DESC, id DESC
`).all(vonDatum, bisDatum);
// Statistics
const stats = { gesamt: bewerbungen.length, positiv: 0, absagen: 0, ausstehend: 0 };
for (const b of bewerbungen) {
if (b.status === 'Vorstellungsgespräch' || b.status === 'Einstellung') stats.positiv++;
else if (b.status === 'Absage') stats.absagen++;
else stats.ausstehend++;
}
// Years for filter dropdown
const dbJahre = db.prepare(
`SELECT DISTINCT strftime('%Y', datum) AS j FROM bewerbungen ORDER BY j DESC`
).all().map(r => parseInt(r.j)).filter(Boolean);
const jahre = [...new Set([...dbJahre, now.getFullYear()])].sort((a, b) => b - a);
res.render('index', {
bewerbungen,
bewerbungenJson: safeJson(bewerbungen),
stats,
monat,
jahr,
monate: MONATE,
jahre,
artOptionen: ART_OPTIONEN,
statusOptionen: STATUS_OPTIONEN,
settings: getSettings(),
monatName: MONATE[monat - 1],
fehler: req.query.fehler || null,
currentPage: 'uebersicht'
});
});
// POST /bewerbungen Neu anlegen
app.post('/bewerbungen', (req, res) => {
const { datum, firma, stelle, art, status, notizen } = req.body;
if (!datum || !firma || !stelle) {
const d = new Date();
return res.redirect(`/?fehler=pflichtfelder&monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
}
db.prepare(`
INSERT INTO bewerbungen (datum, firma, stelle, art, status, notizen)
VALUES (?, ?, ?, ?, ?, ?)
`).run(
sanitize(datum, 20),
sanitize(firma),
sanitize(stelle),
sanitize(art),
sanitize(status),
sanitize(notizen)
);
const d = new Date(datum + 'T00:00:00');
res.redirect(`/?monat=${d.getMonth() + 1}&jahr=${d.getFullYear()}`);
});
// PUT /bewerbungen/:id Aktualisieren
app.put('/bewerbungen/:id', (req, res) => {
const id = parseInt(req.params.id);
const { datum, firma, stelle, art, status, notizen, monat, jahr } = req.body;
if (!datum || !firma || !stelle) {
return res.redirect(`/?fehler=pflichtfelder&monat=${monat}&jahr=${jahr}`);
}
db.prepare(`
UPDATE bewerbungen
SET datum=?, firma=?, stelle=?, art=?, status=?, notizen=?, updated_at=CURRENT_TIMESTAMP
WHERE id=?
`).run(
sanitize(datum, 20),
sanitize(firma),
sanitize(stelle),
sanitize(art),
sanitize(status),
sanitize(notizen),
id
);
res.redirect(`/?monat=${monat || 1}&jahr=${jahr || new Date().getFullYear()}`);
});
// DELETE /bewerbungen/:id Löschen
app.delete('/bewerbungen/:id', (req, res) => {
db.prepare('DELETE FROM bewerbungen WHERE id=?').run(parseInt(req.params.id));
const { monat, jahr } = req.body;
const now = new Date();
res.redirect(`/?monat=${monat || now.getMonth() + 1}&jahr=${jahr || now.getFullYear()}`);
});
// GET /einstellungen
app.get('/einstellungen', (req, res) => {
res.render('einstellungen', {
settings: getSettings(),
gespeichert: req.query.gespeichert === '1',
currentPage: 'einstellungen'
});
});
// POST /einstellungen
app.post('/einstellungen', (req, res) => {
const { name, adresse, kundennummer } = req.body;
db.prepare(`
INSERT OR REPLACE INTO settings (id, name, adresse, kundennummer)
VALUES (1, ?, ?, ?)
`).run(sanitize(name), sanitize(adresse), sanitize(kundennummer));
res.redirect('/einstellungen?gespeichert=1');
});
// GET /api/pdf-daten JSON für clientseitige PDF-Generierung
app.get('/api/pdf-daten', (req, res) => {
const monat = Math.min(12, Math.max(1, parseInt(req.query.monat) || 1));
const jahr = parseInt(req.query.jahr) || new Date().getFullYear();
const ms = String(monat).padStart(2, '0');
const bewerbungen = db.prepare(`
SELECT * FROM bewerbungen
WHERE datum BETWEEN ? AND ?
ORDER BY datum ASC, id ASC
`).all(`${jahr}-${ms}-01`, `${jahr}-${ms}-31`);
res.json({ bewerbungen, settings: getSettings(), monat, jahr });
});
// ── Start ─────────────────────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`\n✓ Bewerbungs-Tracker läuft auf → http://localhost:${PORT}\n`);
});