feat: Schritte 14–15 — Google Calendar OAuth + Apple CalDAV Sync + Settings-Seite

- server/services/google-calendar.js: OAuth 2.0, bidirektionaler Sync via
  Google Calendar API v3, inkrementeller syncToken, 410-Fallback auf Vollsync
- server/services/apple-calendar.js: CalDAV via tsdav (dynamic ESM import),
  minimaler ICS-Parser + ICS-Builder, bidirektionaler Sync
- server/routes/calendar.js: 7 neue Sync-Routen (google/auth, google/callback,
  google/sync, google/status, google/disconnect, apple/status, apple/sync)
- server/db.js: Migration 2 — sync_config Tabelle + idx_calendar_external_id
- server/db-schema-test.js: MIGRATIONS_SQL[2] für Tests synchronisiert
- server/auth.js: PATCH /me/password Endpoint
- server/index.js: Auto-Sync-Scheduler (setInterval, SYNC_INTERVAL_MINUTES)
- public/pages/settings.js: vollständige Settings-Seite (Konto, Passwort,
  Kalender-Sync-Status + Aktionen, Familienmitglieder-Verwaltung)
- public/styles/settings.css: neue Stylesheet-Datei
- public/index.html + public/sw.js: settings.css eingebunden und gecacht
- .env.example: SYNC_INTERVAL_MINUTES ergänzt
- README.md: vollständige Setup-Anleitung, Google/Apple-Sync-Dokumentation,
  modernes GitHub-Layout mit Badges und aufklappbaren Abschnitten

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-24 22:53:44 +01:00
parent 81d4000ee1
commit 72d6d5126e
13 changed files with 1693 additions and 131 deletions
+126 -3
View File
@@ -7,9 +7,12 @@
'use strict';
const express = require('express');
const router = express.Router();
const db = require('../db');
const express = require('express');
const router = express.Router();
const db = require('../db');
const googleCalendar = require('../services/google-calendar');
const appleCalendar = require('../services/apple-calendar');
const { requireAdmin } = require('../auth');
const VALID_SOURCES = ['local', 'google', 'apple'];
const DATETIME_RE = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2})?Z?)?$/;
@@ -100,6 +103,126 @@ router.get('/upcoming', (req, res) => {
}
});
// --------------------------------------------------------
// Google Calendar Sync-Routen
// Alle vor /:id registriert, um Konflikte zu vermeiden.
// --------------------------------------------------------
/**
* GET /api/v1/calendar/google/auth
* Admin only. Leitet zum Google OAuth-Consent-Screen weiter.
*/
router.get('/google/auth', requireAdmin, (req, res) => {
try {
const url = googleCalendar.getAuthUrl();
if (!url) return res.status(503).json({ error: 'Google nicht konfiguriert.', code: 503 });
res.redirect(url);
} catch (err) {
console.error('[calendar/google/auth]', err);
res.status(503).json({ error: err.message, code: 503 });
}
});
/**
* GET /api/v1/calendar/google/callback
* OAuth-Callback von Google. Tauscht Code gegen Tokens und startet initialen Sync.
* Query: ?code=...
*/
router.get('/google/callback', async (req, res) => {
try {
const { code, error } = req.query;
if (error) return res.redirect('/settings?sync_error=google');
if (!code) return res.status(400).json({ error: 'Kein Code erhalten.', code: 400 });
await googleCalendar.handleCallback(code);
// Initialen Sync im Hintergrund starten (kein await — Redirect soll sofort erfolgen)
googleCalendar.sync().catch((e) => console.error('[Google] Initialer Sync fehlgeschlagen:', e.message));
res.redirect('/settings?sync_ok=google');
} catch (err) {
console.error('[calendar/google/callback]', err);
res.redirect('/settings?sync_error=google');
}
});
/**
* POST /api/v1/calendar/google/sync
* Manueller Sync-Trigger.
* Response: { ok: true, lastSync: string }
*/
router.post('/google/sync', async (req, res) => {
try {
await googleCalendar.sync();
const { lastSync } = googleCalendar.getStatus();
res.json({ ok: true, lastSync });
} catch (err) {
console.error('[calendar/google/sync]', err);
res.status(500).json({ error: err.message, code: 500 });
}
});
/**
* GET /api/v1/calendar/google/status
* Response: { configured, connected, lastSync }
*/
router.get('/google/status', (req, res) => {
try {
res.json(googleCalendar.getStatus());
} catch (err) {
console.error('[calendar/google/status]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* DELETE /api/v1/calendar/google/disconnect
* Admin only. Tokens löschen und Verbindung trennen.
* Response: { ok: true }
*/
router.delete('/google/disconnect', requireAdmin, (req, res) => {
try {
googleCalendar.disconnect();
res.json({ ok: true });
} catch (err) {
console.error('[calendar/google/disconnect]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
// --------------------------------------------------------
// Apple Calendar Sync-Routen
// --------------------------------------------------------
/**
* GET /api/v1/calendar/apple/status
* Response: { configured, lastSync }
*/
router.get('/apple/status', (req, res) => {
try {
res.json(appleCalendar.getStatus());
} catch (err) {
console.error('[calendar/apple/status]', err);
res.status(500).json({ error: 'Interner Fehler', code: 500 });
}
});
/**
* POST /api/v1/calendar/apple/sync
* Manueller Sync-Trigger.
* Response: { ok: true, lastSync: string }
*/
router.post('/apple/sync', async (req, res) => {
try {
await appleCalendar.sync();
const { lastSync } = appleCalendar.getStatus();
res.json({ ok: true, lastSync });
} catch (err) {
console.error('[calendar/apple/sync]', err);
res.status(500).json({ error: err.message, code: 500 });
}
});
// --------------------------------------------------------
// GET /api/v1/calendar/:id
// Einzelnen Termin abrufen.