diff --git a/.env.example b/.env.example
index bd5e88a..8c1e1ff 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,15 @@ MAIL_FROM=name@example.com
MAIL_IMAP_MAILBOX=INBOX
MAIL_POLL_MS=180000
+# --- Bewerbungskalender (CalDAV, z. B. SOGo) ---
+# Voll-URL der Kalender-Sammlung (mit abschließendem /). Auth läuft über die
+# oben gesetzten MAIL_USER/MAIL_PASSWORD (gleiches Konto). Ohne CALDAV_URL sind
+# die Kalender-Funktionen deaktiviert.
+# CALDAV_URL=https://mail.example.com/SOGo/dav/name@example.com/Calendar/XXXX/
+# Standard-Erinnerung (Minuten vor Terminbeginn) und Sync-Intervall (ms).
+CALDAV_ALARM_MIN=60
+CALDAV_POLL_MS=300000
+
# --- REST-API für Drittanbietersoftware (/api/v1) ---
# Ist dieser Schlüssel gesetzt, ist die API aktiv und erwartet den Wert im
# Header "X-API-Key" jedes Requests. Ohne Schlüssel antwortet die API (bis auf
diff --git a/lib/caldav.js b/lib/caldav.js
new file mode 100644
index 0000000..4303b51
--- /dev/null
+++ b/lib/caldav.js
@@ -0,0 +1,329 @@
+// Minimal CalDAV client for the application calendar (SOGo).
+//
+// Two-way sync: reads events in a time window (REPORT calendar-query) and
+// creates / updates / deletes events (PUT / DELETE with iCalendar VEVENTs).
+// Auth is HTTP Basic over HTTPS using the same mail account as lib/mailer.
+//
+// Config (env):
+// CALDAV_URL full URL of the calendar collection (must end with /)
+// MAIL_USER login (shared with mail)
+// MAIL_PASSWORD password (shared with mail)
+// CALDAV_ALARM_MIN default reminder lead time in minutes (default 60)
+//
+// Times are handled as UTC instants internally; the UI renders them in
+// Europe/Berlin. Wall-clock input from the UI is converted with wallToUtc().
+
+const crypto = require('crypto');
+
+const TZ = 'Europe/Berlin';
+
+function cfg() {
+ return {
+ url: (process.env.CALDAV_URL || '').trim(),
+ user: process.env.MAIL_USER || '',
+ pass: process.env.MAIL_PASSWORD || '',
+ alarmMin: Number(process.env.CALDAV_ALARM_MIN) || 60,
+ };
+}
+
+function isConfigured() {
+ const c = cfg();
+ return !!(c.url && c.user && c.pass);
+}
+
+function collectionUrl() {
+ let u = cfg().url;
+ if (u && !u.endsWith('/')) u += '/';
+ return u;
+}
+
+function authHeader() {
+ const c = cfg();
+ return 'Basic ' + Buffer.from(`${c.user}:${c.pass}`).toString('base64');
+}
+
+// ---------------------------------------------------------------------------
+// Time helpers
+// ---------------------------------------------------------------------------
+
+const pad = (n) => String(n).padStart(2, '0');
+
+// Offset (ms) of a time zone at a given instant: local(tz) - UTC.
+function tzOffsetMs(timeZone, date) {
+ const utc = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' }));
+ const loc = new Date(date.toLocaleString('en-US', { timeZone }));
+ return loc.getTime() - utc.getTime();
+}
+
+// Interpret (y, mo[1-12], d, h, mi) as wall-clock time in `timeZone` and return
+// the corresponding UTC Date (DST-correct, including boundary re-check).
+function wallToUtc(y, mo, d, h, mi, timeZone = TZ) {
+ const asIfUtc = new Date(Date.UTC(y, mo - 1, d, h, mi, 0));
+ const off1 = tzOffsetMs(timeZone, asIfUtc);
+ let result = new Date(asIfUtc.getTime() - off1);
+ const off2 = tzOffsetMs(timeZone, result);
+ if (off2 !== off1) result = new Date(asIfUtc.getTime() - off2);
+ return result;
+}
+
+// "YYYY-MM-DDTHH:MM" (local Berlin wall time from a form) -> UTC Date.
+function localInputToUtc(dateStr, timeStr) {
+ const [y, mo, d] = String(dateStr).split('-').map(Number);
+ const [h, mi] = String(timeStr || '00:00').split(':').map(Number);
+ return wallToUtc(y, mo, d, h || 0, mi || 0, TZ);
+}
+
+// UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
+function toIcsUtc(date) {
+ return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
+ + 'T' + pad(date.getUTCHours()) + pad(date.getUTCMinutes()) + pad(date.getUTCSeconds()) + 'Z';
+}
+
+// UTC Date -> iCalendar DATE "YYYYMMDD" in Europe/Berlin (for all-day events).
+function toIcsDate(date) {
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' })
+ .formatToParts(date).reduce((a, p) => (a[p.type] = p.value, a), {});
+ return `${parts.year}${parts.month}${parts.day}`;
+}
+
+// Parse an iCalendar DTSTART/DTEND value (+params) to { date: Date, allDay }.
+function icsToDate(value, params) {
+ const v = String(value).trim();
+ const tzid = (params && params.TZID) || null;
+ if (/^\d{8}$/.test(v)) {
+ const y = +v.slice(0, 4), mo = +v.slice(4, 6), d = +v.slice(6, 8);
+ return { date: wallToUtc(y, mo, d, 0, 0, tzid || TZ), allDay: true };
+ }
+ const m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
+ if (!m) return { date: null, allDay: false };
+ const [, y, mo, d, h, mi, s, z] = m;
+ if (z) return { date: new Date(Date.UTC(+y, +mo - 1, +d, +h, +mi, +s)), allDay: false };
+ // TZID or floating -> treat as wall time in that zone (Berlin fallback).
+ return { date: wallToUtc(+y, +mo, +d, +h, +mi, tzid || TZ), allDay: false };
+}
+
+// ---------------------------------------------------------------------------
+// iCalendar generation
+// ---------------------------------------------------------------------------
+
+function escapeText(s) {
+ return String(s == null ? '' : s)
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\r?\n/g, '\\n');
+}
+
+function unescapeText(s) {
+ return String(s == null ? '' : s)
+ .replace(/\\n/gi, '\n')
+ .replace(/\\,/g, ',')
+ .replace(/\\;/g, ';')
+ .replace(/\\\\/g, '\\');
+}
+
+// Fold a content line to <=75 octets per RFC 5545 (CRLF + single space).
+function fold(line) {
+ if (Buffer.byteLength(line, 'utf8') <= 75) return line;
+ const out = [];
+ let cur = '';
+ for (const ch of line) {
+ if (Buffer.byteLength(cur + ch, 'utf8') > 73) { out.push(cur); cur = ' ' + ch; }
+ else cur += ch;
+ }
+ if (cur) out.push(cur);
+ return out.join('\r\n');
+}
+
+// Build a full VCALENDAR/VEVENT document.
+// evt: { uid, summary, location, description, start(Date), end(Date), allDay, alarmMin }
+function buildICS(evt) {
+ const uid = evt.uid || (crypto.randomUUID() + '@bewerbungs-tracker');
+ const lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Bewerbungs-Tracker//Kalender//DE',
+ 'CALSCALE:GREGORIAN',
+ 'BEGIN:VEVENT',
+ 'UID:' + uid,
+ 'DTSTAMP:' + toIcsUtc(new Date()),
+ ];
+ if (evt.allDay) {
+ lines.push('DTSTART;VALUE=DATE:' + toIcsDate(evt.start));
+ const endDate = evt.end || new Date(evt.start.getTime() + 24 * 3600 * 1000);
+ lines.push('DTEND;VALUE=DATE:' + toIcsDate(endDate));
+ } else {
+ lines.push('DTSTART:' + toIcsUtc(evt.start));
+ const end = evt.end || new Date(evt.start.getTime() + 60 * 60 * 1000);
+ lines.push('DTEND:' + toIcsUtc(end));
+ }
+ lines.push('SUMMARY:' + escapeText(evt.summary || 'Termin'));
+ if (evt.location) lines.push('LOCATION:' + escapeText(evt.location));
+ if (evt.description) lines.push('DESCRIPTION:' + escapeText(evt.description));
+ const alarm = Number(evt.alarmMin);
+ if (!evt.allDay && alarm > 0) {
+ lines.push('BEGIN:VALARM', 'ACTION:DISPLAY', 'DESCRIPTION:' + escapeText(evt.summary || 'Termin'),
+ 'TRIGGER:-PT' + Math.round(alarm) + 'M', 'END:VALARM');
+ }
+ lines.push('END:VEVENT', 'END:VCALENDAR');
+ return { uid, body: lines.map(fold).join('\r\n') + '\r\n' };
+}
+
+// ---------------------------------------------------------------------------
+// iCalendar parsing (single VEVENT out of a VCALENDAR)
+// ---------------------------------------------------------------------------
+
+function unfold(ics) {
+ const raw = String(ics).split(/\r\n|\n|\r/);
+ const out = [];
+ for (const line of raw) {
+ if ((line.startsWith(' ') || line.startsWith('\t')) && out.length) out[out.length - 1] += line.slice(1);
+ else out.push(line);
+ }
+ return out;
+}
+
+function parseVEvent(ics) {
+ const lines = unfold(ics);
+ let inEvent = false, nested = 0;
+ const ev = {};
+ for (const line of lines) {
+ if (line === 'BEGIN:VEVENT') { inEvent = true; continue; }
+ if (line === 'END:VEVENT') break;
+ if (!inEvent) continue;
+ // Skip nested components (e.g. VALARM) so their fields don't leak in.
+ if (line.startsWith('BEGIN:')) { nested++; continue; }
+ if (line.startsWith('END:')) { if (nested > 0) nested--; continue; }
+ if (nested > 0) continue;
+ const idx = line.indexOf(':');
+ if (idx === -1) continue;
+ const namePart = line.slice(0, idx);
+ const value = line.slice(idx + 1);
+ const [name, ...paramParts] = namePart.split(';');
+ const params = {};
+ paramParts.forEach((p) => { const [k, v] = p.split('='); if (k) params[k.toUpperCase()] = v; });
+ const key = name.toUpperCase();
+ if (key === 'UID') ev.uid = value.trim();
+ else if (key === 'SUMMARY') ev.summary = unescapeText(value);
+ else if (key === 'LOCATION') ev.location = unescapeText(value);
+ else if (key === 'DESCRIPTION') ev.description = unescapeText(value);
+ else if (key === 'DTSTART') { const r = icsToDate(value, params); ev.start = r.date; ev.allDay = r.allDay; }
+ else if (key === 'DTEND') { const r = icsToDate(value, params); ev.end = r.date; }
+ }
+ return ev.uid || ev.start ? ev : null;
+}
+
+// ---------------------------------------------------------------------------
+// HTTP / CalDAV
+// ---------------------------------------------------------------------------
+
+async function request(method, url, { headers = {}, body } = {}) {
+ const res = await fetch(url, {
+ method,
+ headers: { Authorization: authHeader(), ...headers },
+ body,
+ });
+ const text = await res.text().catch(() => '');
+ if (res.status === 401) throw new Error('CalDAV-Anmeldung fehlgeschlagen (401) — Zugangsdaten prüfen.');
+ return { status: res.status, ok: res.ok, headers: res.headers, text };
+}
+
+function decodeXml(s) {
+ return String(s).replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, "'").replace(/
/g, '\r').replace(/&/g, '&');
+}
+
+function tagContent(xml, tag) {
+ const m = xml.match(new RegExp('<[^>]*' + tag + '[^>]*>([\\s\\S]*?)[^>]*' + tag + '>', 'i'));
+ return m ? m[1] : null;
+}
+
+// Change tag of the collection — cheap way to detect any change.
+async function getCtag() {
+ if (!isConfigured()) return null;
+ const r = await request('PROPFIND', collectionUrl(), {
+ headers: { Depth: '0', 'Content-Type': 'application/xml; charset=utf-8' },
+ body: '',
+ });
+ return tagContent(r.text, 'getctag');
+}
+
+// List events overlapping [from, to] (Date objects). Returns parsed events with
+// href/etag so they can be updated/deleted later.
+async function listEvents({ from, to }) {
+ if (!isConfigured()) return [];
+ const body =
+ '' +
+ '' +
+ '' +
+ `` +
+ '';
+ const r = await request('REPORT', collectionUrl(), {
+ headers: { Depth: '1', 'Content-Type': 'application/xml; charset=utf-8' },
+ body,
+ });
+ const events = [];
+ const responses = r.text.match(/<[^>]*:?response[^>]*>[\s\S]*?<\/[^>]*:?response>/gi) || [];
+ for (const resp of responses) {
+ const href = tagContent(resp, 'href');
+ const etag = tagContent(resp, 'getetag');
+ const calData = tagContent(resp, 'calendar-data');
+ if (!calData) continue;
+ const ev = parseVEvent(decodeXml(calData));
+ if (!ev || !ev.start) continue;
+ ev.href = href ? new URL(decodeXml(href).trim(), collectionUrl()).toString() : null;
+ ev.etag = etag ? etag.replace(/^["']|["']$/g, '').trim() : null;
+ events.push(ev);
+ }
+ return events;
+}
+
+async function createEvent(evt) {
+ if (!isConfigured()) throw new Error('CalDAV ist nicht konfiguriert.');
+ const built = buildICS(evt);
+ const url = collectionUrl() + encodeURIComponent(built.uid) + '.ics';
+ const r = await request('PUT', url, {
+ headers: { 'Content-Type': 'text/calendar; charset=utf-8', 'If-None-Match': '*' },
+ body: built.body,
+ });
+ if (!r.ok) throw new Error(`Termin konnte nicht angelegt werden (HTTP ${r.status}).`);
+ return { uid: built.uid, href: url, etag: (r.headers.get('etag') || '').replace(/^["']|["']$/g, '') || null };
+}
+
+async function updateEvent({ href, etag, ...evt }) {
+ if (!isConfigured()) throw new Error('CalDAV ist nicht konfiguriert.');
+ const built = buildICS(evt);
+ const headers = { 'Content-Type': 'text/calendar; charset=utf-8' };
+ if (etag) headers['If-Match'] = `"${etag}"`;
+ const r = await request('PUT', href, { headers, body: built.body });
+ if (!r.ok) throw new Error(`Termin konnte nicht aktualisiert werden (HTTP ${r.status}).`);
+ return { uid: evt.uid || built.uid, href, etag: (r.headers.get('etag') || '').replace(/^["']|["']$/g, '') || null };
+}
+
+async function deleteEvent({ href, etag }) {
+ if (!isConfigured() || !href) return;
+ const headers = {};
+ if (etag) headers['If-Match'] = `"${etag}"`;
+ const r = await request('DELETE', href, { headers });
+ // 404/412 are non-fatal: the event is already gone or changed remotely.
+ if (!r.ok && r.status !== 404 && r.status !== 412) {
+ throw new Error(`Termin konnte nicht gelöscht werden (HTTP ${r.status}).`);
+ }
+}
+
+module.exports = {
+ TZ,
+ isConfigured,
+ collectionUrl,
+ wallToUtc,
+ localInputToUtc,
+ buildICS,
+ parseVEvent,
+ getCtag,
+ listEvents,
+ createEvent,
+ updateEvent,
+ deleteEvent,
+ // exported for tests
+ _internals: { toIcsUtc, toIcsDate, icsToDate, escapeText, unescapeText, fold, unfold },
+};
diff --git a/server.js b/server.js
index 0faae73..eba322a 100644
--- a/server.js
+++ b/server.js
@@ -32,6 +32,7 @@ const mailer = require('./lib/mailer');
const { createExternalApi } = require('./lib/api');
const { buildOpenApiSpec } = require('./lib/openapi');
const blacklist = require('./lib/blacklist');
+const caldav = require('./lib/caldav');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -269,6 +270,58 @@ async function autoBlacklistOffer(offer, grund) {
await insertBlacklistEntry(blacklist.buildAutoEntry(offer, grund));
}
+// ---------------------------------------------------------------------------
+// Application calendar (CalDAV) helpers
+// ---------------------------------------------------------------------------
+
+// Upcoming appointments (not yet ended), newest first, for the dashboard widget.
+async function upcomingTermine(limit = 6) {
+ const now = new Date().toISOString();
+ return dbAll(
+ `SELECT t.*, b.firma AS bewerbung_firma, b.stelle AS bewerbung_stelle
+ FROM termine t LEFT JOIN bewerbungen b ON b.id = t.bewerbung_id
+ WHERE COALESCE(t.ende, t.start) >= ?
+ ORDER BY t.start ASC LIMIT ?`,
+ [now, limit]
+ );
+}
+
+// Reconcile our tracked appointments with the SOGo calendar: reflect remote
+// edits and drop entries deleted remotely. Cheap ctag check first. Best-effort.
+async function refreshCaldav() {
+ if (!caldav.isConfigured()) return;
+ const ctag = await caldav.getCtag().catch(() => null);
+ if (ctag) {
+ const prev = await getState('caldav_ctag');
+ if (prev && prev === ctag) return;
+ }
+ const from = new Date(Date.now() - 24 * 3600 * 1000);
+ const to = new Date(Date.now() + 180 * 24 * 3600 * 1000);
+ const remote = await caldav.listEvents({ from, to });
+ const byUid = new Map(remote.map((e) => [e.uid, e]));
+ const local = await dbAll(
+ 'SELECT * FROM termine WHERE caldav_uid IS NOT NULL AND start >= ? AND start <= ?',
+ [from.toISOString(), to.toISOString()]
+ );
+ for (const t of local) {
+ const r = byUid.get(t.caldav_uid);
+ if (!r) {
+ await dbRun('DELETE FROM termine WHERE id = ?', [t.id]);
+ } else {
+ await dbRun(
+ `UPDATE termine SET titel = ?, ort = ?, notiz = ?, start = ?, ende = ?, ganztags = ?,
+ caldav_etag = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
+ [
+ sanitizeInput(r.summary || t.titel), sanitizeInput(r.location || ''), sanitizeInput(r.description || ''),
+ (r.start || new Date(t.start)).toISOString(), r.end ? r.end.toISOString() : null,
+ r.allDay ? 1 : 0, r.etag || t.caldav_etag, t.id,
+ ]
+ );
+ }
+ }
+ if (ctag) await setState('caldav_ctag', ctag);
+}
+
// ---------------------------------------------------------------------------
// E-Mail correspondence: IMAP polling, storing & matching incoming replies
// ---------------------------------------------------------------------------
@@ -702,6 +755,29 @@ function initializeDatabase() {
// Remember the last recipient address per application (prefill).
db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {});
+ // Application calendar appointments, mirrored to the SOGo CalDAV
+ // calendar (see lib/caldav). Times are stored as UTC ISO strings.
+ db.run(`
+ CREATE TABLE IF NOT EXISTS termine (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ bewerbung_id INTEGER,
+ typ TEXT NOT NULL DEFAULT 'termin',
+ titel TEXT NOT NULL,
+ ort TEXT,
+ notiz TEXT,
+ start TEXT NOT NULL,
+ ende TEXT,
+ ganztags INTEGER DEFAULT 0,
+ erinnerung_min INTEGER DEFAULT 60,
+ caldav_uid TEXT,
+ caldav_href TEXT,
+ caldav_etag TEXT,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+ FOREIGN KEY (bewerbung_id) REFERENCES bewerbungen(id) ON DELETE SET NULL
+ )
+ `, () => {});
+
db.run(`
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
@@ -771,6 +847,8 @@ initializeDatabase().then(() => {
const applications = await dbAll(query, params);
await attachVerlauf(applications);
const settings = await dbGet('SELECT * FROM settings WHERE id = 1');
+ // Upcoming calendar appointments for the dashboard widget.
+ const kommendeTermine = await upcomingTermine(6);
// Get statistics
const totalCount = await dbGet('SELECT COUNT(*) as count FROM bewerbungen');
@@ -803,6 +881,8 @@ initializeDatabase().then(() => {
},
availableMonths,
currentFilter: { month, year },
+ kommendeTermine,
+ caldavTz: caldav.TZ,
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS
});
@@ -1085,6 +1165,8 @@ initializeDatabase().then(() => {
const basisCountRow = await dbGet('SELECT COUNT(*) as count FROM basis_dokumente');
// Available static attachments (Zeugnisse etc.) to optionally enclose.
const basisAnhaenge = await dbAll('SELECT id, name, dateiname FROM basis_anhaenge ORDER BY id ASC');
+ // Calendar appointments for this application (mirrored to SOGo).
+ const termine = await dbAll('SELECT * FROM termine WHERE bewerbung_id = ? ORDER BY start ASC', [id]);
// E-Mail correspondence (sent + received), oldest first, with attachments.
const emails = await dbAll(
@@ -1120,6 +1202,12 @@ initializeDatabase().then(() => {
mailOk: req.query.mailok ? String(req.query.mailok) : '',
basisCount: basisCountRow ? basisCountRow.count : 0,
basisAnhaenge,
+ termine,
+ caldavConfigured: caldav.isConfigured(),
+ caldavTz: caldav.TZ,
+ terminVorschlag: req.query.vorschlag === 'vg' ? { datum: String(req.query.vdatum || '') } : null,
+ terminOk: !!req.query.terminok,
+ terminError: req.query.terminerror ? String(req.query.terminerror) : '',
artOptions: ART_OPTIONS,
statusOptions: STATUS_OPTIONS,
hideSettings: true
@@ -1380,6 +1468,11 @@ initializeDatabase().then(() => {
[id, datum, sanitizeInput(status), sanitizeInput(kommentar || '')]
);
await syncCurrentStatus(id);
+
+ // Suggest a calendar entry when an interview was recorded (confirm + click).
+ if (caldav.isConfigured() && /vorstellungsgespr/i.test(status)) {
+ return res.redirect('/bewerbung/' + id + '?vorschlag=vg&vdatum=' + encodeURIComponent(datum) + '#termine');
+ }
}
res.redirect('/bewerbung/' + id);
@@ -1389,6 +1482,67 @@ initializeDatabase().then(() => {
}
});
+ // --- Application calendar appointments (mirrored to SOGo via CalDAV) ---
+ app.post('/bewerbung/:id/termine', async (req, res) => {
+ const { id } = req.params;
+ try {
+ const bewerbung = await dbGet('SELECT id FROM bewerbungen WHERE id = ?', [id]);
+ if (!bewerbung) return res.status(404).send('Bewerbung nicht gefunden');
+ if (!caldav.isConfigured()) {
+ return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Kalender ist nicht konfiguriert.') + '#termine');
+ }
+ const b = req.body || {};
+ if (!b.datum) {
+ return res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent('Bitte ein Datum angeben.') + '#termine');
+ }
+ const ganztags = b.ganztags === 'on' || b.ganztags === '1' || b.ganztags === 'true';
+ const typ = b.typ === 'vorstellungsgespraech' ? 'vorstellungsgespraech' : 'termin';
+ const titel = (b.titel || '').trim() || (typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin');
+ const erinnerung = Math.max(0, parseInt(b.erinnerung_min, 10) || 0);
+
+ let start, ende = null;
+ if (ganztags) {
+ const [y, mo, d] = String(b.datum).split('-').map(Number);
+ start = caldav.wallToUtc(y, mo, d, 0, 0);
+ } else {
+ start = caldav.localInputToUtc(b.datum, b.von || '09:00');
+ if (b.bis) ende = caldav.localInputToUtc(b.datum, b.bis);
+ }
+
+ const created = await caldav.createEvent({
+ summary: titel, location: b.ort || '', description: b.notiz || '',
+ start, end: ende, allDay: ganztags, alarmMin: erinnerung,
+ });
+
+ await dbRun(
+ `INSERT INTO termine (bewerbung_id, typ, titel, ort, notiz, start, ende, ganztags, erinnerung_min, caldav_uid, caldav_href, caldav_etag)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [id, typ, sanitizeInput(titel), sanitizeInput(b.ort || ''), sanitizeInput(b.notiz || ''),
+ start.toISOString(), ende ? ende.toISOString() : null, ganztags ? 1 : 0, erinnerung,
+ created.uid, created.href, created.etag]
+ );
+ res.redirect('/bewerbung/' + id + '?terminok=1#termine');
+ } catch (error) {
+ console.error('Error creating termin:', error);
+ res.redirect('/bewerbung/' + id + '?terminerror=' + encodeURIComponent(error.message || 'Termin konnte nicht angelegt werden.') + '#termine');
+ }
+ });
+
+ app.post('/bewerbung/:id/termine/:tid/delete', async (req, res) => {
+ const { id, tid } = req.params;
+ try {
+ const t = await dbGet('SELECT * FROM termine WHERE id = ? AND bewerbung_id = ?', [tid, id]);
+ if (t) {
+ await caldav.deleteEvent({ href: t.caldav_href, etag: t.caldav_etag }).catch((e) => console.warn('CalDAV delete:', e.message));
+ await dbRun('DELETE FROM termine WHERE id = ?', [tid]);
+ }
+ res.redirect('/bewerbung/' + id + '#termine');
+ } catch (error) {
+ console.error('Error deleting termin:', error);
+ res.redirect('/bewerbung/' + id + '#termine');
+ }
+ });
+
// Update a timeline entry
app.post('/bewerbung/:id/verlauf/:eintragId', async (req, res) => {
try {
@@ -1996,6 +2150,16 @@ initializeDatabase().then(() => {
console.log('E-Mail nicht konfiguriert (MAIL_HOST/MAIL_USER/MAIL_PASSWORD fehlen) - Versand/Empfang deaktiviert.');
}
+ // Calendar: reconcile our appointments with the SOGo CalDAV calendar.
+ if (caldav.isConfigured()) {
+ console.log(`Kalender aktiv: CalDAV ${caldav.collectionUrl()}`);
+ const calPoll = Math.max(60000, Number(process.env.CALDAV_POLL_MS) || 300000);
+ setTimeout(() => { refreshCaldav().catch(() => {}); }, 10000); // initial sync after boot
+ setInterval(() => { refreshCaldav().catch(() => {}); }, calPoll); // periodic reconcile
+ } else {
+ console.log('Kalender nicht konfiguriert (CALDAV_URL fehlt) - Kalender-Funktionen deaktiviert.');
+ }
+
// Handle 404
app.use((req, res) => {
res.status(404).send('Seite nicht gefunden');
diff --git a/views/bewerbung.ejs b/views/bewerbung.ejs
index 4b4a5bd..29aef44 100644
--- a/views/bewerbung.ejs
+++ b/views/bewerbung.ejs
@@ -521,6 +521,130 @@
+
+
+ <% if (caldavConfigured) { %>
+
+
+
+ <% if (terminOk) { %>
+ Termin wurde im Kalender angelegt.
+ <% } %>
+ <% if (terminError) { %>
+ <%= terminError %>
+ <% } %>
+ <% if (terminVorschlag) { %>
+
+ Vorstellungsgespräch erfasst – möchtest du den Termin in den Kalender aufnehmen? Zeit prüfen und speichern.
+
+ <% } %>
+
+ <% if (termine && termine.length) { %>
+
+ <% termine.forEach(function(t){
+ var when = t.ganztags
+ ? new Date(t.start).toLocaleDateString('de-DE', { timeZone: caldavTz, day: '2-digit', month: 'long', year: 'numeric' })
+ : new Date(t.start).toLocaleString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
+ var bis = (!t.ganztags && t.ende) ? new Date(t.ende).toLocaleTimeString('de-DE', { timeZone: caldavTz, hour: '2-digit', minute: '2-digit' }) : '';
+ %>
+ -
+
+
+ <%= t.typ === 'vorstellungsgespraech' ? 'Vorstellungsgespräch' : 'Termin' %>
+ <%= t.titel %>
+
+
<%= when %><% if (bis) { %>–<%= bis %> Uhr<% } %>
+ <% if (t.ort) { %>
📍 <%= t.ort %>
<% } %>
+ <% if (t.notiz) { %>
<%= t.notiz %>
<% } %>
+
+
+
+ <% }); %>
+
+ <% } else { %>
+ Noch keine Termine für diese Bewerbung.
+ <% } %>
+
+ class="border-t border-gray-200 dark:border-gray-700 pt-3">
+
+
+ Termin hinzufügen
+
+
+
+
+
+ <% } %>
<%- include('partials/footer') %>
diff --git a/views/index.ejs b/views/index.ejs
index dac2658..ce9b113 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -96,6 +96,40 @@
+
+ <% if (typeof kommendeTermine !== 'undefined' && kommendeTermine && kommendeTermine.length) { %>
+
+
+
+ <% kommendeTermine.forEach(function(t){
+ var when = t.ganztags
+ ? new Date(t.start).toLocaleDateString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' })
+ : new Date(t.start).toLocaleString('de-DE', { timeZone: caldavTz, weekday: 'short', day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
+ %>
+ -
+
+
+ <%= t.typ === 'vorstellungsgespraech' ? 'Gespräch' : 'Termin' %>
+ <%= t.titel %>
+
+
+ <%= when %> Uhr<% if (t.bewerbung_firma) { %> · <%= t.bewerbung_firma %><% } %>
+
+
+ <% if (t.bewerbung_id) { %>
+ Öffnen
+
+
+ <% } %>
+
+ <% }); %>
+
+
+ <% } %>
+
Statistik