From 95b5b8510d1af3a88f8cd3eaf854614ef562ec2b Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Sat, 4 Jul 2026 20:06:38 +0200 Subject: [PATCH] Add a two-way application calendar (CalDAV / SOGo) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New lib/caldav.js speaks CalDAV over Basic auth (shared mail account): reads events in a window, and creates/updates/deletes iCalendar VEVENTs with a reminder alarm. DST-correct Europe/Berlin <-> UTC handling. Appointments (Vorstellungsgespräch / general) are managed per application in a new "Termine" section and mirrored to the SOGo calendar; recording a "Vorstellungsgespräch" status suggests a prefilled calendar entry (confirm + click). A dashboard widget lists upcoming appointments. A background poller reconciles remote edits/deletions via the collection ctag. Config: CALDAV_URL (+ CALDAV_ALARM_MIN, CALDAV_POLL_MS); disabled gracefully when unset. Co-Authored-By: Claude Opus 4.8 --- .env.example | 9 ++ lib/caldav.js | 329 ++++++++++++++++++++++++++++++++++++++++++++ server.js | 164 ++++++++++++++++++++++ views/bewerbung.ejs | 124 +++++++++++++++++ views/index.ejs | 34 +++++ 5 files changed, 660 insertions(+) create mode 100644 lib/caldav.js 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) { %> +
+
+ +

Termine

+
+ + <% 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) { %> +
+
+ +

Anstehende Termine

+
+
    + <% 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