diff --git a/.env.example b/.env.example
index dc97130..8317dce 100644
--- a/.env.example
+++ b/.env.example
@@ -28,6 +28,9 @@ APPLE_CALDAV_URL=https://caldav.icloud.com
APPLE_USERNAME=
APPLE_APP_SPECIFIC_PASSWORD=
+# Kalender-Sync-Intervall in Minuten (Standard: 15)
+SYNC_INTERVAL_MINUTES=15
+
# Sicherheit
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_ATTEMPTS=5
diff --git a/README.md b/README.md
index 7553099..3d35c8a 100644
--- a/README.md
+++ b/README.md
@@ -1,178 +1,284 @@
-# Oikos — Selbstgehosteter Familienplaner
+
-Oikos ist eine self-hosted Progressive Web App für Familien. Sie läuft vollständig auf deinem eigenen Server — keine Cloud-Abhängigkeiten, keine Datenweitergabe.
+# 🏠 Oikos
-## Features
+**Selbstgehosteter Familienplaner — privat, offen, ohne Abonnement**
-- **Dashboard** — Begrüßung, Wetter-Widget, anstehende Termine, dringende Aufgaben, Essen, Pinnwand
-- **Aufgaben** — Listenansicht (Kategorie/Fälligkeit), Kanban-Board, Teilaufgaben, Swipe-Gesten, wiederkehrende Aufgaben
-- **Einkaufslisten** — Mehrere Listen, Kategorien, Essensplan-Integration
-- **Essensplan** — Wochenansicht, Zutatenverwaltung, Übertrag auf Einkaufsliste
-- **Kalender** — Monats-/Wochen-/Tages-/Agenda-Ansicht, Familienfarben, wiederkehrende Termine
-- **Pinnwand** — Farbige Sticky Notes mit Markdown-Light
-- **Kontakte** — Wichtige Kontakte mit Kategorie-Filter, tel:/mailto:/Maps-Links
-- **Budget** — Einnahmen/Ausgaben, Monatsauswertung, CSV-Export
-- **PWA** — Offline-fähig, installierbar auf iOS/Android/Desktop
+[](https://nodejs.org)
+[](https://www.docker.com)
+[](https://www.zetetic.net/sqlcipher/)
+[](https://web.dev/progressive-web-apps/)
+[](./LICENSE)
-## Voraussetzungen
+Alle Daten bleiben auf deinem eigenen Server.
+Kein Cloud-Zwang. Keine Datenweitergabe. Kein Tracking.
-- **Docker & Docker Compose** (empfohlen) oder **Node.js ≥ 20**
-- Ein Linux-Server hinter Nginx Reverse Proxy mit SSL (empfohlen)
+[Module](#module) · [Schnellstart](#schnellstart) · [Konfiguration](#konfiguration) · [Kalender-Sync](#kalender-synchronisation) · [Sicherheit](#sicherheit)
+
+
---
-## Schnellstart mit Docker (empfohlen)
+## Module
-### 1. Repository klonen
+| | Modul | Highlights |
+|---|---|---|
+| 📋 | **Dashboard** | Wetter-Widget, anstehende Termine, dringende Aufgaben, Essen heute, Pinnwand-Vorschau |
+| ✅ | **Aufgaben** | Listenansicht + Kanban, Teilaufgaben, Swipe-Gesten, wiederkehrende Aufgaben (RRULE) |
+| 🛒 | **Einkauf** | Mehrere Listen, automatische Kategorie-Sortierung, Integration mit Essensplan |
+| 🍽️ | **Essensplan** | Wochenansicht, Zutatenverwaltung, Zutaten → Einkaufsliste mit einem Klick |
+| 📅 | **Kalender** | Monats-/Wochen-/Tages-/Agenda-Ansicht, Google Calendar & Apple Calendar Sync |
+| 📌 | **Pinnwand** | Farbige Sticky Notes, Markdown-Light (fett, kursiv, Listen) |
+| 👥 | **Kontakte** | Wichtige Familien-Kontakte, Direktanruf (`tel:`), Maps-Links |
+| 💰 | **Budget** | Einnahmen/Ausgaben, Kategorien, Monatsvergleich, CSV-Export |
+| ⚙️ | **Einstellungen** | Passwort ändern, Kalender-Sync verwalten, Familienmitglieder anlegen |
+
+---
+
+## Tech Stack
+
+**Backend:** Node.js · Express · SQLite/SQLCipher · express-session · bcrypt
+
+**Frontend:** Vanilla JavaScript (ES-Module) · Kein Framework · Kein Build-Step
+
+**Deployment:** Docker · Nginx Reverse Proxy · PWA (Service Worker + Manifest)
+
+**Optional:** Google Calendar API v3 (OAuth 2.0) · Apple iCloud CalDAV (tsdav)
+
+---
+
+## Schnellstart
+
+### Voraussetzungen
+
+- **Docker** + **Docker Compose**
+- Ein Linux-Server mit Nginx Reverse Proxy und SSL (empfohlen: [Nginx Proxy Manager](https://nginxproxymanager.com))
+
+### 1 — Repository klonen
```bash
git clone https://github.com/ulsklyc/oikos.git
cd oikos
```
-### 2. Umgebungsvariablen konfigurieren
+### 2 — Umgebungsvariablen setzen
```bash
cp .env.example .env
```
-Pflichtfelder in `.env` anpassen:
+Mindestens diese zwei Pflichtfelder in `.env` ausfüllen:
```env
-SESSION_SECRET=ein-langer-zufaelliger-string-min-32-zeichen
-DB_ENCRYPTION_KEY=ein-starkes-passwort-fuer-die-datenbank
+# Langen zufälligen String (≥ 32 Zeichen)
+SESSION_SECRET=...
+
+# AES-256-Schlüssel für SQLCipher-Datenbankverschlüsselung
+DB_ENCRYPTION_KEY=...
```
-Optional: Wetter-Widget aktivieren
+> Vollständige Variablen-Referenz → [Konfiguration](#konfiguration)
-```env
-OPENWEATHER_API_KEY=dein-api-key-von-openweathermap.org
-OPENWEATHER_CITY=Berlin
-```
-
-### 3. Starten
+### 3 — Container starten
```bash
docker compose up -d
```
-Die App ist unter `http://localhost:3000` erreichbar.
+> Der erste Build dauert 2–3 Minuten (SQLCipher wird gegen better-sqlite3 kompiliert).
-### 4. Erster Login
-
-Beim ersten Start wird automatisch ein Admin-Account erstellt:
-
-```
-Benutzername: admin
-Passwort: admin
-```
-
-**Passwort sofort ändern!** → Einstellungen → Passwort ändern
-
----
-
-## Ohne Docker (direkt mit Node.js)
+### 4 — Admin-Account anlegen
```bash
-npm install
-cp .env.example .env
-# .env anpassen (siehe oben)
-npm start
+docker compose exec oikos node setup.js
```
-Entwicklungsmodus (Auto-Reload):
+Das interaktive Script fragt nach Benutzername, Anzeigename und Passwort. Dieser Account hat Admin-Rechte und kann weitere Familienmitglieder anlegen.
-```bash
-npm run dev
-```
+### 5 — App öffnen
+
+`http://localhost:3000` — oder die konfigurierte Domain nach dem Nginx-Setup.
---
## Nginx Reverse Proxy
-Beispiel-Konfiguration für Nginx Proxy Manager oder direktes Nginx:
+Die Datei [`nginx.conf.example`](./nginx.conf.example) enthält eine vollständige Konfiguration.
-```nginx
-server {
- listen 443 ssl;
- server_name oikos.deine-domain.de;
+**Mit Nginx Proxy Manager:**
- location / {
- proxy_pass http://localhost:3000;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
- }
-}
+1. Neuen Proxy Host anlegen: `oikos.deine-domain.de` → `localhost:3000`
+2. SSL-Zertifikat via Let's Encrypt ausstellen
+3. Inhalt aus `nginx.conf.example` im Feld "Advanced" eintragen
+
+**Wichtig:** `X-Forwarded-Proto` muss gesetzt sein (in der Vorlage enthalten), damit Session-Cookies in Produktion korrekt als `Secure` gesetzt werden.
+
+---
+
+## Konfiguration
+
+### Pflicht
+
+| Variable | Beschreibung |
+|---|---|
+| `SESSION_SECRET` | Zufälliger String ≥ 32 Zeichen für Session-Signing |
+| `DB_ENCRYPTION_KEY` | SQLCipher AES-256-Schlüssel (leer = keine Verschlüsselung) |
+
+### Wetter-Widget
+
+Kostenlosen API-Key bei [openweathermap.org](https://openweathermap.org/api) registrieren:
+
+```env
+OPENWEATHER_API_KEY=...
+OPENWEATHER_CITY=Berlin
+OPENWEATHER_UNITS=metric # metric = °C, imperial = °F
+OPENWEATHER_LANG=de
```
-Die Datei `nginx.conf.example` im Repository enthält eine vollständige Konfiguration.
+### Weitere Optionen
+
+| Variable | Standard | Beschreibung |
+|---|---|---|
+| `PORT` | `3000` | Server-Port |
+| `NODE_ENV` | `development` | `production` für Deployment |
+| `DB_PATH` | `./oikos.db` | Pfad zur SQLite-Datei |
+| `SYNC_INTERVAL_MINUTES` | `15` | Automatischer Kalender-Sync-Intervall |
+| `RATE_LIMIT_MAX_ATTEMPTS` | `5` | Max. Login-Versuche pro Minute |
+
+Vollständige Vorlage: [`.env.example`](./.env.example)
---
-## Familienmitglieder verwalten
+## Kalender-Synchronisation
-Neue Mitglieder können nur Admins anlegen:
+### Google Calendar
-1. **Einstellungen** → **Familienmitglieder** → **+ Neu**
-2. Benutzername, Anzeigename, Passwort, Avatarfarbe und Rolle festlegen
-3. Login-Daten dem Familienmitglied mitteilen
+
+
-
-
Kommt bald.
-
Dieses Modul wird in Phase 2 implementiert.
+
Einstellungen
+
+ ${syncOk ? `
Kalender-Sync mit ${syncOk === 'google' ? 'Google' : 'Apple'} erfolgreich verbunden.
` : ''}
+ ${syncErr ? `
Verbindung mit ${syncErr === 'google' ? 'Google' : 'Apple'} fehlgeschlagen. Bitte erneut versuchen.
` : ''}
+
+
+
+ Mein Konto
+
+
+
+
+ ${initials(user?.display_name)}
+
+
+
${user?.display_name ?? ''}
+
@${user?.username ?? ''}
+
+
+
+
+
+
+
+
+
+ Kalender-Synchronisation
+
+
+
+
+ ${googleStatus.configured ? `
+
+ ${googleStatus.connected ? `
+
+ ${user?.role === 'admin' ? `
` : ''}
+ ` : `
+ ${user?.role === 'admin' ? `
Mit Google verbinden` : '
Nur Admin kann Google Calendar verbinden.'}
+ `}
+
+ ` : ''}
+
+
+
+
+
+ ${appleStatus.configured ? `
+
+
+
+ ` : ''}
+
+
+
+
+ ${user?.role === 'admin' ? `
+
+ Familienmitglieder
+
+
+ ${users.map(memberHtml).join('')}
+
+
+
+
+
+
+ ` : ''}
+
+
+
`;
+
+ bindEvents(container, user);
+}
+
+// --------------------------------------------------------
+// Event-Binding
+// --------------------------------------------------------
+
+function bindEvents(container, user) {
+ // Passwort ändern
+ const passwordForm = container.querySelector('#password-form');
+ if (passwordForm) {
+ passwordForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const currentPw = container.querySelector('#current-password').value;
+ const newPw = container.querySelector('#new-password').value;
+ const confirmPw = container.querySelector('#confirm-password').value;
+ const errorEl = container.querySelector('#password-error');
+
+ errorEl.hidden = true;
+
+ if (newPw !== confirmPw) {
+ showError(errorEl, 'Passwörter stimmen nicht überein.');
+ return;
+ }
+
+ const btn = passwordForm.querySelector('[type=submit]');
+ btn.disabled = true;
+ try {
+ await api.patch('/auth/me/password', { current_password: currentPw, new_password: newPw });
+ passwordForm.reset();
+ window.oikos?.showToast('Passwort erfolgreich geändert.', 'success');
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ }
+
+ // Google Sync
+ const googleSyncBtn = container.querySelector('#google-sync-btn');
+ if (googleSyncBtn) {
+ googleSyncBtn.addEventListener('click', async () => {
+ googleSyncBtn.disabled = true;
+ googleSyncBtn.textContent = 'Synchronisiere…';
+ try {
+ await api.post('/calendar/google/sync', {});
+ window.oikos?.showToast('Google Calendar synchronisiert.', 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ } finally {
+ googleSyncBtn.disabled = false;
+ googleSyncBtn.textContent = 'Jetzt synchronisieren';
+ }
+ });
+ }
+
+ // Google Disconnect (Admin)
+ const googleDisconnectBtn = container.querySelector('#google-disconnect-btn');
+ if (googleDisconnectBtn) {
+ googleDisconnectBtn.addEventListener('click', async () => {
+ if (!confirm('Google Calendar-Verbindung trennen?')) return;
+ try {
+ await api.delete('/calendar/google/disconnect');
+ window.oikos?.showToast('Google Calendar getrennt.', 'default');
+ window.oikos?.navigate('/settings');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ }
+
+ // Apple Sync
+ const appleSyncBtn = container.querySelector('#apple-sync-btn');
+ if (appleSyncBtn) {
+ appleSyncBtn.addEventListener('click', async () => {
+ appleSyncBtn.disabled = true;
+ appleSyncBtn.textContent = 'Synchronisiere…';
+ try {
+ await api.post('/calendar/apple/sync', {});
+ window.oikos?.showToast('Apple Calendar synchronisiert.', 'success');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ } finally {
+ appleSyncBtn.disabled = false;
+ appleSyncBtn.textContent = 'Jetzt synchronisieren';
+ }
+ });
+ }
+
+ // Mitglied hinzufügen (Admin)
+ const addMemberBtn = container.querySelector('#add-member-btn');
+ if (addMemberBtn) {
+ addMemberBtn.addEventListener('click', () => {
+ container.querySelector('#add-member-form-card').classList.remove('settings-card--hidden');
+ addMemberBtn.hidden = true;
+ });
+ }
+
+ const cancelAddMember = container.querySelector('#cancel-add-member');
+ if (cancelAddMember) {
+ cancelAddMember.addEventListener('click', () => {
+ container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
+ container.querySelector('#add-member-btn').hidden = false;
+ container.querySelector('#add-member-form').reset();
+ container.querySelector('#member-error').hidden = true;
+ });
+ }
+
+ const addMemberForm = container.querySelector('#add-member-form');
+ if (addMemberForm) {
+ addMemberForm.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const errorEl = container.querySelector('#member-error');
+ errorEl.hidden = true;
+
+ const data = {
+ username: container.querySelector('#new-username').value.trim(),
+ display_name: container.querySelector('#new-display-name').value.trim(),
+ password: container.querySelector('#new-member-password').value,
+ avatar_color: container.querySelector('#new-avatar-color').value,
+ role: container.querySelector('#new-role').value,
+ };
+
+ const btn = addMemberForm.querySelector('[type=submit]');
+ btn.disabled = true;
+ try {
+ const res = await auth.createUser(data);
+ const list = container.querySelector('#members-list');
+ list.insertAdjacentHTML('beforeend', memberHtml(res.user));
+ addMemberForm.reset();
+ container.querySelector('#add-member-form-card').classList.add('settings-card--hidden');
+ container.querySelector('#add-member-btn').hidden = false;
+ window.oikos?.showToast(`${res.user.display_name} hinzugefügt.`, 'success');
+ bindDeleteButtons(container, user);
+ } catch (err) {
+ showError(errorEl, err.message);
+ } finally {
+ btn.disabled = false;
+ }
+ });
+ }
+
+ bindDeleteButtons(container, user);
+
+ // Abmelden
+ const logoutBtn = container.querySelector('#logout-btn');
+ if (logoutBtn) {
+ logoutBtn.addEventListener('click', async () => {
+ try {
+ await auth.logout();
+ } finally {
+ window.location.href = '/login';
+ }
+ });
+ }
+}
+
+function bindDeleteButtons(container, user) {
+ container.querySelectorAll('[data-delete-user]').forEach((btn) => {
+ btn.replaceWith(btn.cloneNode(true)); // Doppelte Listener vermeiden
+ });
+ container.querySelectorAll('[data-delete-user]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const id = parseInt(btn.dataset.deleteUser, 10);
+ const name = btn.dataset.name;
+ if (!confirm(`${name} wirklich löschen?`)) return;
+ try {
+ await auth.deleteUser(id);
+ btn.closest('.settings-member').remove();
+ window.oikos?.showToast(`${name} gelöscht.`, 'default');
+ } catch (err) {
+ window.oikos?.showToast(err.message, 'danger');
+ }
+ });
+ });
+}
+
+// --------------------------------------------------------
+// Helfer
+// --------------------------------------------------------
+
+function memberHtml(u) {
+ return `
+
+ ${initials(u.display_name)}
+
+ ${u.display_name}
+ @${u.username} · ${u.role === 'admin' ? 'Admin' : 'Mitglied'}
+
+
+
+ `;
+}
+
+function initials(name) {
+ if (!name) return '?';
+ return name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
+}
+
+function formatDate(iso) {
+ if (!iso) return '';
+ return new Date(iso).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
+}
+
+function showError(el, msg) {
+ el.textContent = msg;
+ el.hidden = false;
}
diff --git a/public/styles/settings.css b/public/styles/settings.css
new file mode 100644
index 0000000..090087f
--- /dev/null
+++ b/public/styles/settings.css
@@ -0,0 +1,276 @@
+/**
+ * Modul: Einstellungen (Settings)
+ * Zweck: Styles für die Settings-Seite
+ * Abhängigkeiten: tokens.css
+ */
+
+/* --------------------------------------------------------
+ Seiten-Layout
+ -------------------------------------------------------- */
+
+.settings-page {
+ max-width: 720px;
+ margin: 0 auto;
+}
+
+/* --------------------------------------------------------
+ Banner (Erfolg / Fehler nach OAuth-Callback)
+ -------------------------------------------------------- */
+
+.settings-banner {
+ padding: 12px 16px;
+ border-radius: var(--radius-sm);
+ margin-bottom: 16px;
+ font-size: 14px;
+ font-weight: 500;
+}
+
+.settings-banner--success {
+ background: rgba(52, 199, 89, 0.12);
+ color: var(--color-success);
+ border: 1px solid rgba(52, 199, 89, 0.3);
+}
+
+.settings-banner--error {
+ background: rgba(255, 59, 48, 0.1);
+ color: var(--color-danger);
+ border: 1px solid rgba(255, 59, 48, 0.25);
+}
+
+/* --------------------------------------------------------
+ Sections
+ -------------------------------------------------------- */
+
+.settings-section {
+ margin-bottom: 32px;
+}
+
+.settings-section__title {
+ font-size: 13px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--color-text-secondary);
+ margin: 0 0 10px 4px;
+}
+
+/* --------------------------------------------------------
+ Cards
+ -------------------------------------------------------- */
+
+.settings-card {
+ background: var(--color-surface);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+ padding: 20px;
+ margin-bottom: 12px;
+}
+
+.settings-card--hidden {
+ display: none;
+}
+
+.settings-card__title {
+ font-size: 15px;
+ font-weight: 600;
+ margin: 0 0 16px;
+ color: var(--color-text-primary);
+}
+
+/* --------------------------------------------------------
+ Benutzerinfo
+ -------------------------------------------------------- */
+
+.settings-user-info {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+
+.settings-user-info__name {
+ font-size: 17px;
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.settings-user-info__username {
+ font-size: 13px;
+ color: var(--color-text-secondary);
+ margin-top: 2px;
+}
+
+/* --------------------------------------------------------
+ Avatar
+ -------------------------------------------------------- */
+
+.settings-avatar {
+ width: 48px;
+ height: 48px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 18px;
+ font-weight: 700;
+ color: #fff;
+ flex-shrink: 0;
+ user-select: none;
+}
+
+.settings-avatar--sm {
+ width: 36px;
+ height: 36px;
+ font-size: 13px;
+}
+
+/* --------------------------------------------------------
+ Formulare
+ -------------------------------------------------------- */
+
+.settings-form {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.settings-form-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+.form-error {
+ font-size: 13px;
+ color: var(--color-danger);
+ padding: 8px 12px;
+ background: rgba(255, 59, 48, 0.08);
+ border-radius: var(--radius-sm);
+}
+
+.form-hint {
+ font-size: 13px;
+ color: var(--color-text-secondary);
+}
+
+.form-input--color {
+ padding: 4px 8px;
+ height: 44px;
+ cursor: pointer;
+}
+
+/* --------------------------------------------------------
+ Sync-Karten
+ -------------------------------------------------------- */
+
+.settings-sync-header {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin-bottom: 14px;
+}
+
+.settings-sync-logo {
+ width: 40px;
+ height: 40px;
+ border-radius: var(--radius-sm);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.settings-sync-logo--google {
+ background: #f8f9fa;
+ border: 1px solid var(--color-border);
+}
+
+.settings-sync-logo--apple {
+ background: #1c1c1e;
+ color: #fff;
+}
+
+.settings-sync-info__name {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--color-text-primary);
+}
+
+.settings-sync-info__status {
+ font-size: 13px;
+ color: var(--color-text-secondary);
+ margin-top: 2px;
+}
+
+.settings-sync-info__status--connected {
+ color: var(--color-success);
+}
+
+.settings-sync-actions {
+ display: flex;
+ gap: 10px;
+ flex-wrap: wrap;
+}
+
+/* --------------------------------------------------------
+ Familienmitglieder
+ -------------------------------------------------------- */
+
+.settings-members {
+ list-style: none;
+ margin: 0 0 16px;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.settings-member {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.settings-member__info {
+ flex: 1;
+ min-width: 0;
+}
+
+.settings-member__name {
+ display: block;
+ font-size: 15px;
+ font-weight: 500;
+ color: var(--color-text-primary);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.settings-member__meta {
+ display: block;
+ font-size: 12px;
+ color: var(--color-text-secondary);
+ margin-top: 1px;
+}
+
+.settings-add-btn {
+ width: 100%;
+}
+
+/* --------------------------------------------------------
+ Abmelden
+ -------------------------------------------------------- */
+
+.settings-logout-btn {
+ width: 100%;
+}
+
+/* --------------------------------------------------------
+ Dark Mode
+ -------------------------------------------------------- */
+
+@media (prefers-color-scheme: dark) {
+ .settings-sync-logo--google {
+ background: #2c2c2e;
+ border-color: var(--color-border);
+ }
+}
diff --git a/public/sw.js b/public/sw.js
index 781abea..af1e41c 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -35,6 +35,7 @@ const APP_SHELL = [
'/styles/notes.css',
'/styles/contacts.css',
'/styles/budget.css',
+ '/styles/settings.css',
'/manifest.json',
];
diff --git a/server/auth.js b/server/auth.js
index 0472685..91034ac 100644
--- a/server/auth.js
+++ b/server/auth.js
@@ -239,6 +239,39 @@ router.post('/users', requireAuth, requireAdmin, async (req, res) => {
}
});
+/**
+ * PATCH /api/v1/auth/me/password
+ * Ändert das eigene Passwort.
+ * Body: { current_password: string, new_password: string }
+ * Response: { ok: true }
+ */
+router.patch('/me/password', requireAuth, async (req, res) => {
+ try {
+ const { current_password, new_password } = req.body;
+
+ if (!current_password || !new_password) {
+ return res.status(400).json({ error: 'Aktuelles und neues Passwort erforderlich.', code: 400 });
+ }
+ if (new_password.length < 8) {
+ return res.status(400).json({ error: 'Neues Passwort muss mindestens 8 Zeichen haben.', code: 400 });
+ }
+
+ const user = db.get().prepare('SELECT password_hash FROM users WHERE id = ?').get(req.session.userId);
+ if (!user) return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 });
+
+ const valid = await bcrypt.compare(current_password, user.password_hash);
+ if (!valid) return res.status(401).json({ error: 'Aktuelles Passwort falsch.', code: 401 });
+
+ const hash = await bcrypt.hash(new_password, 12);
+ db.get().prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, req.session.userId);
+
+ res.json({ ok: true });
+ } catch (err) {
+ console.error('[Auth] Passwort-Ändern-Fehler:', err);
+ res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
+ }
+});
+
/**
* DELETE /api/v1/auth/users/:id
* Admin only. Löscht ein Familienmitglied.
diff --git a/server/db-schema-test.js b/server/db-schema-test.js
index 6ed4a3d..2350ef2 100644
--- a/server/db-schema-test.js
+++ b/server/db-schema-test.js
@@ -172,6 +172,14 @@ const MIGRATIONS_SQL = {
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
`,
+ 2: `
+ CREATE TABLE IF NOT EXISTS sync_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
+ `,
};
module.exports = { MIGRATIONS_SQL };
diff --git a/server/db.js b/server/db.js
index cd13698..fd0665f 100644
--- a/server/db.js
+++ b/server/db.js
@@ -265,8 +265,19 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
`,
},
- // Zukünftige Migrations hier anhängen:
- // { version: 2, description: '...', up: '...' },
+ {
+ version: 2,
+ description: 'Sync-Konfigurationstabelle für Google/Apple Calendar',
+ up: `
+ CREATE TABLE IF NOT EXISTS sync_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
+ `,
+ },
];
/**
diff --git a/server/index.js b/server/index.js
index c5ff2c5..dbcd0ba 100644
--- a/server/index.js
+++ b/server/index.js
@@ -11,9 +11,11 @@ const express = require('express');
const helmet = require('helmet');
const rateLimit = require('express-rate-limit');
const path = require('path');
-const db = require('./db');
+const db = require('./db');
const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth');
const { csrfMiddleware } = require('./middleware/csrf');
+const googleCalendar = require('./services/google-calendar');
+const appleCalendar = require('./services/apple-calendar');
const app = express();
const PORT = process.env.PORT || 3000;
@@ -154,12 +156,37 @@ app.use((err, req, res, _next) => {
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
});
+// --------------------------------------------------------
+// Auto-Sync Scheduler (Google + Apple Calendar)
+// --------------------------------------------------------
+
+const SYNC_INTERVAL_MS = (parseInt(process.env.SYNC_INTERVAL_MINUTES, 10) || 15) * 60_000;
+
+async function runSync() {
+ const { connected: googleConnected } = googleCalendar.getStatus();
+ if (googleConnected) {
+ googleCalendar.sync().catch((e) => console.error('[Sync] Google Fehler:', e.message));
+ }
+
+ const { configured: appleConfigured } = appleCalendar.getStatus();
+ if (appleConfigured) {
+ appleCalendar.sync().catch((e) => console.error('[Sync] Apple Fehler:', e.message));
+ }
+}
+
// --------------------------------------------------------
// Server starten
// --------------------------------------------------------
app.listen(PORT, () => {
console.log(`[Oikos] Server läuft auf Port ${PORT}`);
console.log(`[Oikos] Umgebung: ${process.env.NODE_ENV || 'development'}`);
+
+ // Erster Sync nach 10 Sekunden (warten bis DB vollständig initialisiert)
+ setTimeout(() => {
+ runSync();
+ setInterval(runSync, SYNC_INTERVAL_MS);
+ console.log(`[Sync] Auto-Sync alle ${SYNC_INTERVAL_MS / 60_000} Minuten aktiv.`);
+ }, 10_000);
});
module.exports = app;
diff --git a/server/routes/calendar.js b/server/routes/calendar.js
index 59984f7..82f2a60 100644
--- a/server/routes/calendar.js
+++ b/server/routes/calendar.js
@@ -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.
diff --git a/server/services/apple-calendar.js b/server/services/apple-calendar.js
index 56d1489..fe6df1e 100644
--- a/server/services/apple-calendar.js
+++ b/server/services/apple-calendar.js
@@ -1,11 +1,292 @@
/**
* Modul: Apple Calendar Sync (CalDAV)
* Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll
- * Abhängigkeiten: tsdav, server/db.js
+ * Abhängigkeiten: tsdav (ESM — dynamisch importiert), server/db.js
+ *
+ * Konfiguration (.env):
+ * APPLE_CALDAV_URL — z.B. https://caldav.icloud.com
+ * APPLE_USERNAME — Apple-ID E-Mail
+ * APPLE_APP_SPECIFIC_PASSWORD — App-spezifisches Passwort aus appleid.apple.com
+ *
+ * sync_config-Schlüssel:
+ * apple_last_sync — ISO-8601-Timestamp des letzten Syncs
*/
-// Platzhalter — wird in Phase 3 implementiert
+'use strict';
-module.exports = {
- sync: async () => null,
-};
+const db = require('../db');
+
+const APPLE_COLOR = '#FC3C44';
+
+// --------------------------------------------------------
+// sync_config Helfer
+// --------------------------------------------------------
+
+function cfgGet(key) {
+ const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get(key);
+ return row ? row.value : null;
+}
+
+function cfgSet(key, value) {
+ db.get().prepare(`
+ INSERT INTO sync_config (key, value)
+ VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+ `).run(key, value);
+}
+
+// --------------------------------------------------------
+// Verbindungsstatus
+// --------------------------------------------------------
+
+function getStatus() {
+ const configured = !!(
+ process.env.APPLE_CALDAV_URL &&
+ process.env.APPLE_USERNAME &&
+ process.env.APPLE_APP_SPECIFIC_PASSWORD
+ );
+ const lastSync = cfgGet('apple_last_sync');
+ return { configured, lastSync };
+}
+
+// --------------------------------------------------------
+// Minimaler ICS-Parser
+// --------------------------------------------------------
+
+/**
+ * Entfaltet ICS-Zeilenfortsetzungen (RFC 5545 §3.1).
+ * @param {string} ics
+ * @returns {string}
+ */
+function unfoldLines(ics) {
+ return ics.replace(/\r?\n[ \t]/g, '');
+}
+
+/**
+ * Extrahiert alle VEVENT-Blöcke aus einem ICS-String.
+ * @param {string} ics
+ * @returns {Array<{uid, summary, description, location, dtstart, dtend, rrule, allDay}>}
+ */
+function parseICS(ics) {
+ const unfolded = unfoldLines(ics);
+ const events = [];
+ const vEventRe = /BEGIN:VEVENT([\s\S]*?)END:VEVENT/g;
+ let match;
+
+ while ((match = vEventRe.exec(unfolded)) !== null) {
+ const block = match[1];
+ const get = (prop) => {
+ const re = new RegExp(`^${prop}(?:;[^:]*)?:(.*)$`, 'im');
+ const m = re.exec(block);
+ return m ? m[1].trim() : null;
+ };
+
+ const uid = get('UID');
+ const summary = get('SUMMARY') || '(kein Titel)';
+ const description = get('DESCRIPTION') || null;
+ const location = get('LOCATION') || null;
+ const rrule = get('RRULE') ? `RRULE:${get('RRULE')}` : null;
+
+ // DTSTART — mit optionalem TZID oder VALUE=DATE
+ const dtStartRaw = (() => {
+ const m = /^DTSTART(?:;[^:]*)?:(.*)$/im.exec(block);
+ return m ? m[1].trim() : null;
+ })();
+ const dtEndRaw = (() => {
+ const m = /^DTEND(?:;[^:]*)?:(.*)$/im.exec(block);
+ return m ? m[1].trim() : null;
+ })();
+
+ const allDay = /^DTSTART;VALUE=DATE:/im.test(block);
+ const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay) : null;
+ const dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay) : null;
+
+ if (!uid || !dtstart) continue;
+
+ events.push({ uid, summary, description, location, dtstart, dtend, rrule, allDay });
+ }
+
+ return events;
+}
+
+/**
+ * Konvertiert ICS-Datumswert in ISO-8601-String.
+ * Unterstützt: DATE (20240101), DATE-TIME lokal (20240101T120000),
+ * DATE-TIME UTC (20240101T120000Z), DATE-TIME mit TZID (ignoriert TZID, behandelt als lokal).
+ * @param {string} val
+ * @param {boolean} allDay
+ * @returns {string}
+ */
+function formatICSDate(val, allDay) {
+ if (allDay || /^\d{8}$/.test(val)) {
+ // DATE: YYYYMMDD → YYYY-MM-DD
+ return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`;
+ }
+ // DATE-TIME: YYYYMMDDTHHMMSS[Z]
+ const y = val.slice(0, 4);
+ const mo = val.slice(4, 6);
+ const d = val.slice(6, 8);
+ const h = val.slice(9, 11);
+ const mi = val.slice(11, 13);
+ const s = val.slice(13, 15) || '00';
+ const z = val.endsWith('Z') ? 'Z' : '';
+ return `${y}-${mo}-${d}T${h}:${mi}:${s}${z}`;
+}
+
+// --------------------------------------------------------
+// Minimaler ICS-Builder
+// --------------------------------------------------------
+
+/**
+ * Erstellt einen minimalen ICS-String für ein lokales Event.
+ * @param {{ id, title, description, start_datetime, end_datetime, all_day, location, recurrence_rule }} event
+ * @returns {string}
+ */
+function buildICS(event) {
+ const uid = `oikos-${event.id}@oikos.local`;
+ const now = new Date().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ const lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Oikos//Familienplaner//DE',
+ 'BEGIN:VEVENT',
+ `UID:${uid}`,
+ `DTSTAMP:${now}`,
+ `SUMMARY:${escapeICS(event.title)}`,
+ ];
+
+ if (event.all_day) {
+ const startDate = event.start_datetime.slice(0, 10).replace(/-/g, '');
+ const endDate = (event.end_datetime || event.start_datetime).slice(0, 10).replace(/-/g, '');
+ lines.push(`DTSTART;VALUE=DATE:${startDate}`);
+ lines.push(`DTEND;VALUE=DATE:${endDate}`);
+ } else {
+ const startDt = event.start_datetime.replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ const endDt = (event.end_datetime || event.start_datetime).replace(/[-:]/g, '').replace(/\.\d{3}/, '');
+ lines.push(`DTSTART:${startDt}`);
+ lines.push(`DTEND:${endDt}`);
+ }
+
+ if (event.description) lines.push(`DESCRIPTION:${escapeICS(event.description)}`);
+ if (event.location) lines.push(`LOCATION:${escapeICS(event.location)}`);
+ if (event.recurrence_rule) lines.push(event.recurrence_rule); // z.B. RRULE:FREQ=WEEKLY;BYDAY=MO
+
+ lines.push('END:VEVENT', 'END:VCALENDAR');
+ return lines.join('\r\n');
+}
+
+function escapeICS(str) {
+ return String(str).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\n/g, '\\n');
+}
+
+// --------------------------------------------------------
+// Sync
+// --------------------------------------------------------
+
+/**
+ * Bidirektionaler CalDAV-Sync mit iCloud.
+ * Inbound: iCloud → lokale DB (Upsert via external_calendar_id = UID)
+ * Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → iCloud
+ */
+async function sync() {
+ const caldavUrl = process.env.APPLE_CALDAV_URL;
+ const username = process.env.APPLE_USERNAME;
+ const password = process.env.APPLE_APP_SPECIFIC_PASSWORD;
+
+ if (!caldavUrl || !username || !password) {
+ throw new Error('[Apple] APPLE_CALDAV_URL, APPLE_USERNAME und APPLE_APP_SPECIFIC_PASSWORD müssen gesetzt sein.');
+ }
+
+ // tsdav ist ESM-only — dynamischer Import aus CommonJS
+ const { createDAVClient } = await import('tsdav');
+
+ const client = await createDAVClient({
+ serverUrl: caldavUrl,
+ credentials: { username, password },
+ authMethod: 'Basic',
+ defaultAccountType: 'caldav',
+ });
+
+ const calendars = await client.fetchCalendars();
+ if (!calendars.length) {
+ console.warn('[Apple] Keine Kalender gefunden.');
+ return;
+ }
+
+ // Standard-Kalender: erster nicht-Geburtstags-Kalender
+ const cal = calendars.find((c) => !c.displayName?.toLowerCase().includes('geburts')) || calendars[0];
+
+ const calObjects = await client.fetchCalendarObjects({ calendar: cal });
+
+ // --------------------------------------------------------
+ // Inbound: iCloud → lokal
+ // --------------------------------------------------------
+ for (const obj of calObjects) {
+ const parsed = parseICS(obj.data || '');
+ for (const ev of parsed) {
+ try {
+ const existing = db.get().prepare(
+ `SELECT id FROM calendar_events WHERE external_calendar_id = ? AND external_source = 'apple'`
+ ).get(ev.uid);
+
+ if (existing) {
+ db.get().prepare(`
+ UPDATE calendar_events
+ SET title = ?, description = ?, start_datetime = ?, end_datetime = ?,
+ all_day = ?, location = ?, recurrence_rule = ?
+ WHERE id = ?
+ `).run(
+ ev.summary, ev.description, ev.dtstart, ev.dtend,
+ ev.allDay ? 1 : 0, ev.location, ev.rrule, existing.id
+ );
+ } else {
+ db.get().prepare(`
+ INSERT INTO calendar_events
+ (title, description, start_datetime, end_datetime, all_day,
+ location, color, external_calendar_id, external_source, recurrence_rule, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'apple', ?, 1)
+ `).run(
+ ev.summary, ev.description, ev.dtstart, ev.dtend,
+ ev.allDay ? 1 : 0, ev.location, APPLE_COLOR, ev.uid, ev.rrule
+ );
+ }
+ } catch (err) {
+ console.error(`[Apple] Upsert-Fehler für UID ${ev.uid}:`, err.message);
+ }
+ }
+ }
+
+ // --------------------------------------------------------
+ // Outbound: lokal → iCloud
+ // --------------------------------------------------------
+ const localEvents = db.get().prepare(`
+ SELECT * FROM calendar_events
+ WHERE external_source = 'local' AND external_calendar_id IS NULL
+ `).all();
+
+ for (const event of localEvents) {
+ try {
+ const icsData = buildICS(event);
+ const uid = `oikos-${event.id}@oikos.local`;
+ const filename = `${uid}.ics`;
+
+ await client.createCalendarObject({
+ calendar: cal,
+ filename,
+ iCalString: icsData,
+ });
+
+ db.get().prepare(`
+ UPDATE calendar_events SET external_calendar_id = ?, external_source = 'apple' WHERE id = ?
+ `).run(uid, event.id);
+ } catch (err) {
+ console.error(`[Apple] Outbound-Fehler für Event ${event.id}:`, err.message);
+ }
+ }
+
+ cfgSet('apple_last_sync', new Date().toISOString());
+ console.log(`[Apple] Sync abgeschlossen — ${calObjects.length} Objekte inbound, ${localEvents.length} lokal → iCloud.`);
+}
+
+module.exports = { sync, getStatus };
diff --git a/server/services/google-calendar.js b/server/services/google-calendar.js
index b7f7ef5..20dbe97 100644
--- a/server/services/google-calendar.js
+++ b/server/services/google-calendar.js
@@ -2,12 +2,323 @@
* Modul: Google Calendar Sync
* Zweck: OAuth 2.0 + bidirektionaler Sync mit Google Calendar API v3
* Abhängigkeiten: googleapis, server/db.js
+ *
+ * sync_config-Schlüssel:
+ * google_access_token — OAuth Access Token
+ * google_refresh_token — OAuth Refresh Token (langlebig)
+ * google_token_expiry — ISO-8601-Timestamp bis wann Access Token gültig ist
+ * google_sync_token — Inkrementeller Sync-Token von Google (events.list)
+ * google_last_sync — ISO-8601-Timestamp des letzten erfolgreichen Syncs
*/
-// Platzhalter — wird in Phase 3 implementiert
+'use strict';
-module.exports = {
- getAuthUrl: () => null,
- handleCallback: async () => null,
- sync: async () => null,
-};
+const { google } = require('googleapis');
+const db = require('../db');
+
+const GOOGLE_COLOR = '#4285F4';
+
+// --------------------------------------------------------
+// OAuth2-Client (lazy initialisiert)
+// --------------------------------------------------------
+
+function createClient() {
+ const clientId = process.env.GOOGLE_CLIENT_ID;
+ const clientSecret = process.env.GOOGLE_CLIENT_SECRET;
+ const redirectUri = process.env.GOOGLE_REDIRECT_URI;
+
+ if (!clientId || !clientSecret || !redirectUri) {
+ throw new Error('[Google] GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET und GOOGLE_REDIRECT_URI müssen gesetzt sein.');
+ }
+
+ return new google.auth.OAuth2(clientId, clientSecret, redirectUri);
+}
+
+// --------------------------------------------------------
+// sync_config Helfer
+// --------------------------------------------------------
+
+function cfgGet(key) {
+ const row = db.get().prepare('SELECT value FROM sync_config WHERE key = ?').get(key);
+ return row ? row.value : null;
+}
+
+function cfgSet(key, value) {
+ db.get().prepare(`
+ INSERT INTO sync_config (key, value)
+ VALUES (?, ?)
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value,
+ updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
+ `).run(key, value);
+}
+
+function cfgDel(key) {
+ db.get().prepare('DELETE FROM sync_config WHERE key = ?').run(key);
+}
+
+// --------------------------------------------------------
+// Client mit gespeicherten Tokens laden
+// --------------------------------------------------------
+
+function loadAuthorizedClient() {
+ const accessToken = cfgGet('google_access_token');
+ const refreshToken = cfgGet('google_refresh_token');
+
+ if (!accessToken || !refreshToken) {
+ throw new Error('[Google] Nicht konfiguriert — zuerst OAuth durchführen.');
+ }
+
+ const client = createClient();
+ client.setCredentials({
+ access_token: accessToken,
+ refresh_token: refreshToken,
+ expiry_date: cfgGet('google_token_expiry') ? parseInt(cfgGet('google_token_expiry'), 10) : undefined,
+ });
+
+ // Token-Refresh automatisch speichern
+ client.on('tokens', (tokens) => {
+ if (tokens.access_token) cfgSet('google_access_token', tokens.access_token);
+ if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
+ });
+
+ return client;
+}
+
+// --------------------------------------------------------
+// Öffentliche API
+// --------------------------------------------------------
+
+/**
+ * Generiert die Google OAuth2-URL zum Weiterleiten des Admins.
+ * @returns {string} Auth-URL
+ */
+function getAuthUrl() {
+ const client = createClient();
+ return client.generateAuthUrl({
+ access_type: 'offline',
+ prompt: 'consent',
+ scope: ['https://www.googleapis.com/auth/calendar'],
+ });
+}
+
+/**
+ * OAuth-Callback: tauscht Code gegen Tokens, speichert in sync_config.
+ * @param {string} code — Code aus dem OAuth-Callback-Query-Parameter
+ */
+async function handleCallback(code) {
+ const client = createClient();
+ const { tokens } = await client.getToken(code);
+
+ if (!tokens.refresh_token) {
+ throw new Error('[Google] Kein Refresh Token erhalten. Bitte Zugriff in Google-Konto widerrufen und erneut verbinden.');
+ }
+
+ cfgSet('google_access_token', tokens.access_token);
+ cfgSet('google_refresh_token', tokens.refresh_token);
+ if (tokens.expiry_date) cfgSet('google_token_expiry', String(tokens.expiry_date));
+
+ console.log('[Google] OAuth erfolgreich — Tokens gespeichert.');
+}
+
+/**
+ * Verbindungsstatus zurückgeben.
+ * @returns {{ configured: boolean, connected: boolean, lastSync: string|null }}
+ */
+function getStatus() {
+ const configured = !!(process.env.GOOGLE_CLIENT_ID && process.env.GOOGLE_CLIENT_SECRET && process.env.GOOGLE_REDIRECT_URI);
+ const connected = !!(cfgGet('google_access_token') && cfgGet('google_refresh_token'));
+ const lastSync = cfgGet('google_last_sync');
+ return { configured, connected, lastSync };
+}
+
+/**
+ * Tokens und Sync-State löschen (Verbindung trennen).
+ */
+function disconnect() {
+ ['google_access_token', 'google_refresh_token', 'google_token_expiry',
+ 'google_sync_token', 'google_last_sync'].forEach(cfgDel);
+ console.log('[Google] Verbindung getrennt.');
+}
+
+/**
+ * Bidirektionaler Sync.
+ * Inbound: Google → lokale DB (Upsert via external_calendar_id)
+ * Outbound: lokale Termine (external_source='local', external_calendar_id IS NULL) → Google
+ */
+async function sync() {
+ const client = loadAuthorizedClient();
+ const calendar = google.calendar({ version: 'v3', auth: client });
+
+ // --------------------------------------------------------
+ // Inbound: Google → lokal
+ // --------------------------------------------------------
+ let syncToken = cfgGet('google_sync_token');
+ let pageToken = undefined;
+ let newSyncToken = null;
+
+ do {
+ let listParams = {
+ calendarId: 'primary',
+ singleEvents: true,
+ pageToken,
+ };
+
+ if (syncToken) {
+ listParams.syncToken = syncToken;
+ } else {
+ // Erstsync: letzte 3 Monate + nächste 12 Monate
+ const timeMin = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString();
+ const timeMax = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString();
+ listParams.timeMin = timeMin;
+ listParams.timeMax = timeMax;
+ }
+
+ let response;
+ try {
+ response = await calendar.events.list(listParams);
+ } catch (err) {
+ if (err.code === 410) {
+ // syncToken abgelaufen → vollständiger Resync
+ console.warn('[Google] syncToken ungültig — vollständiger Resync.');
+ cfgDel('google_sync_token');
+ syncToken = null;
+ continue;
+ }
+ throw err;
+ }
+
+ const items = response.data.items || [];
+ upsertGoogleEvents(items);
+
+ pageToken = response.data.nextPageToken;
+ newSyncToken = response.data.nextSyncToken || newSyncToken;
+ } while (pageToken);
+
+ if (newSyncToken) cfgSet('google_sync_token', newSyncToken);
+
+ // --------------------------------------------------------
+ // Outbound: lokal → Google
+ // --------------------------------------------------------
+ const localEvents = db.get().prepare(`
+ SELECT * FROM calendar_events
+ WHERE external_source = 'local' AND external_calendar_id IS NULL
+ `).all();
+
+ for (const event of localEvents) {
+ try {
+ const gEvent = localEventToGoogle(event);
+ const created = await calendar.events.insert({
+ calendarId: 'primary',
+ requestBody: gEvent,
+ });
+ db.get().prepare(`
+ UPDATE calendar_events SET external_calendar_id = ?, external_source = 'google' WHERE id = ?
+ `).run(created.data.id, event.id);
+ } catch (err) {
+ console.error(`[Google] Outbound-Fehler für Event ${event.id}:`, err.message);
+ }
+ }
+
+ cfgSet('google_last_sync', new Date().toISOString());
+ console.log(`[Google] Sync abgeschlossen — ${localEvents.length} lokal → Google, Inbound via syncToken.`);
+}
+
+// --------------------------------------------------------
+// Helfer: Google-Event in lokale DB upserten
+// --------------------------------------------------------
+
+function upsertGoogleEvents(items) {
+ const upsert = db.get().prepare(`
+ INSERT INTO calendar_events
+ (title, description, start_datetime, end_datetime, all_day,
+ location, color, external_calendar_id, external_source, recurrence_rule, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'google', ?, 1)
+ ON CONFLICT(external_calendar_id) DO UPDATE SET
+ title = excluded.title,
+ description = excluded.description,
+ start_datetime = excluded.start_datetime,
+ end_datetime = excluded.end_datetime,
+ all_day = excluded.all_day,
+ location = excluded.location,
+ recurrence_rule = excluded.recurrence_rule
+ `);
+
+ const del = db.get().prepare(`
+ DELETE FROM calendar_events WHERE external_calendar_id = ? AND external_source = 'google'
+ `);
+
+ // Erst external_calendar_id UNIQUE index anlegen falls noch nicht vorhanden
+ // (Migration 2 legt idx_calendar_external_id an, aber kein UNIQUE constraint)
+ // Wir nutzen stattdessen manuelles Upsert mit SELECT + INSERT/UPDATE
+ const insertOrUpdate = db.transaction((item) => {
+ if (item.status === 'cancelled') {
+ del.run(item.id);
+ return;
+ }
+
+ const allDay = !!(item.start?.date && !item.start?.dateTime);
+ const startDt = allDay ? item.start.date : (item.start?.dateTime || item.start?.date);
+ const endDt = allDay ? (item.end?.date || null) : (item.end?.dateTime || item.end?.date || null);
+ const title = item.summary || '(kein Titel)';
+ const description = item.description || null;
+ const location = item.location || null;
+ const rrule = item.recurrence ? item.recurrence[0] : null;
+
+ const existing = db.get().prepare(
+ 'SELECT id FROM calendar_events WHERE external_calendar_id = ? AND external_source = ?'
+ ).get(item.id, 'google');
+
+ if (existing) {
+ db.get().prepare(`
+ UPDATE calendar_events
+ SET title = ?, description = ?, start_datetime = ?, end_datetime = ?,
+ all_day = ?, location = ?, recurrence_rule = ?
+ WHERE id = ?
+ `).run(title, description, startDt, endDt, allDay ? 1 : 0, location, rrule, existing.id);
+ } else {
+ db.get().prepare(`
+ INSERT INTO calendar_events
+ (title, description, start_datetime, end_datetime, all_day,
+ location, color, external_calendar_id, external_source, recurrence_rule, created_by)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'google', ?, 1)
+ `).run(title, description, startDt, endDt, allDay ? 1 : 0, location, GOOGLE_COLOR, item.id, rrule);
+ }
+ });
+
+ for (const item of items) {
+ try {
+ insertOrUpdate(item);
+ } catch (err) {
+ console.error(`[Google] Upsert-Fehler für Event ${item.id}:`, err.message);
+ }
+ }
+}
+
+// --------------------------------------------------------
+// Helfer: lokales Event → Google Calendar Event Format
+// --------------------------------------------------------
+
+function localEventToGoogle(event) {
+ const allDay = !!event.all_day;
+ const gEvent = {
+ summary: event.title,
+ description: event.description || undefined,
+ location: event.location || undefined,
+ };
+
+ if (allDay) {
+ gEvent.start = { date: event.start_datetime.slice(0, 10) };
+ gEvent.end = { date: event.end_datetime ? event.end_datetime.slice(0, 10) : event.start_datetime.slice(0, 10) };
+ } else {
+ gEvent.start = { dateTime: event.start_datetime, timeZone: 'Europe/Berlin' };
+ gEvent.end = { dateTime: event.end_datetime || event.start_datetime, timeZone: 'Europe/Berlin' };
+ }
+
+ if (event.recurrence_rule) {
+ gEvent.recurrence = [event.recurrence_rule];
+ }
+
+ return gEvent;
+}
+
+module.exports = { getAuthUrl, handleCallback, getStatus, disconnect, sync };