Add a two-way application calendar (CalDAV / SOGo)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user