From 72d6d5126e3e7f09d65a784b13bc79f559e49059 Mon Sep 17 00:00:00 2001 From: ulsklyc <108589275+ulsklyc@users.noreply.github.com> Date: Tue, 24 Mar 2026 22:53:44 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Schritte=2014=E2=80=9315=20=E2=80=94=20?= =?UTF-8?q?Google=20Calendar=20OAuth=20+=20Apple=20CalDAV=20Sync=20+=20Set?= =?UTF-8?q?tings-Seite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 3 + README.md | 316 +++++++++++++++-------- public/index.html | 1 + public/pages/settings.js | 399 ++++++++++++++++++++++++++++- public/styles/settings.css | 276 ++++++++++++++++++++ public/sw.js | 1 + server/auth.js | 33 +++ server/db-schema-test.js | 8 + server/db.js | 15 +- server/index.js | 29 ++- server/routes/calendar.js | 129 +++++++++- server/services/apple-calendar.js | 291 ++++++++++++++++++++- server/services/google-calendar.js | 323 ++++++++++++++++++++++- 13 files changed, 1693 insertions(+), 131 deletions(-) create mode 100644 public/styles/settings.css 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 +[![Node.js](https://img.shields.io/badge/Node.js-%3E%3D20-339933?logo=node.js&logoColor=white)](https://nodejs.org) +[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?logo=docker&logoColor=white)](https://www.docker.com) +[![SQLite](https://img.shields.io/badge/SQLite-SQLCipher%20verschlüsselt-003B57?logo=sqlite&logoColor=white)](https://www.zetetic.net/sqlcipher/) +[![PWA](https://img.shields.io/badge/PWA-offline--fähig-5A0FC8?logo=pwa&logoColor=white)](https://web.dev/progressive-web-apps/) +[![Lizenz](https://img.shields.io/badge/Lizenz-privat-lightgrey)](./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 +
+Einrichtung anzeigen + +#### Google Cloud Console vorbereiten + +1. Projekt unter [console.cloud.google.com](https://console.cloud.google.com) anlegen +2. **Google Calendar API** aktivieren +3. **OAuth 2.0-Client-ID** erstellen (Typ: „Webanwendung") +4. Autorisierte Redirect-URI eintragen: + ``` + https://oikos.deine-domain.de/api/v1/calendar/google/callback + ``` +5. In `.env` eintragen: + ```env + GOOGLE_CLIENT_ID=... + GOOGLE_CLIENT_SECRET=... + GOOGLE_REDIRECT_URI=https://oikos.deine-domain.de/api/v1/calendar/google/callback + ``` +6. Container neu starten: `docker compose up -d` + +#### Verbindung herstellen + +1. Mit einem **Admin**-Konto einloggen +2. **Einstellungen → Kalender-Synchronisation → Mit Google verbinden** +3. Google-Konto autorisieren → automatische Weiterleitung zurück + +**Sync-Verhalten:** +- Erster Sync: Events der letzten 3 Monate + nächsten 12 Monate +- Folge-Syncs: nur Änderungen via Google syncToken (effizient) +- Outbound: neue lokale Termine werden nach Google übertragen +- Konflikt: Google gewinnt bei gleichzeitiger Änderung + +
+ +### Apple Calendar (iCloud CalDAV) + +
+Einrichtung anzeigen + +#### App-spezifisches Passwort erstellen + +1. [appleid.apple.com](https://appleid.apple.com) → „Anmeldung und Sicherheit" → „App-spezifische Passwörter" +2. Neues Passwort für „Oikos" erstellen +3. In `.env` eintragen: + ```env + APPLE_CALDAV_URL=https://caldav.icloud.com + APPLE_USERNAME=deine@apple-id.de + APPLE_APP_SPECIFIC_PASSWORD=xxxx-xxxx-xxxx-xxxx + ``` +4. Container neu starten: `docker compose up -d` + +Der Sync-Button erscheint automatisch in den Einstellungen. + +
--- -## Umgebungsvariablen — Referenz +## Familienmitglieder -| Variable | Pflicht | Standard | Beschreibung | -|---|---|---|---| -| `PORT` | — | `3000` | Server-Port | -| `NODE_ENV` | — | `development` | `production` für Deployment | -| `SESSION_SECRET` | ✓ | — | Langer Zufalls-String (≥ 32 Zeichen) | -| `DB_PATH` | — | `./oikos.db` | Pfad zur SQLite-Datenbankdatei | -| `DB_ENCRYPTION_KEY` | — | — | SQLCipher-Schlüssel (leer = keine Verschlüsselung) | -| `OPENWEATHER_API_KEY` | — | — | API-Key von openweathermap.org | -| `OPENWEATHER_CITY` | — | `Berlin` | Stadtname für Wetter-Abfrage | -| `OPENWEATHER_UNITS` | — | `metric` | `metric` = °C, `imperial` = °F | -| `OPENWEATHER_LANG` | — | `de` | Sprache der Wetterbeschreibungen | -| `RATE_LIMIT_WINDOW_MS` | — | `60000` | Login Rate-Limit Fenster (ms) | -| `RATE_LIMIT_MAX_ATTEMPTS` | — | `5` | Max. Login-Versuche pro Fenster | +Neue Mitglieder können nur Admins anlegen — es gibt keinen öffentlichen Registrierungs-Endpoint. + +**Im Browser:** Einstellungen → Familienmitglieder → Mitglied hinzufügen + +**Per Script** (z.B. für weiteren Admin): +```bash +docker compose exec oikos node setup.js +``` --- -## Sicherheit +## Updates -- Sessions sind `httpOnly`, `SameSite=Strict` und in Produktion `Secure` -- CSRF-Schutz via Double Submit Cookie auf allen zustandsändernden Requests -- Passwörter mit bcrypt (Cost Factor 12) gehasht -- Globaler Rate-Limiter auf allen API-Endpoints (300 req/min) -- Strikter Login-Rate-Limiter (5 Versuche/Minute) -- Content Security Policy via Helmet -- Datenbank optional mit SQLCipher verschlüsselt +```bash +git pull +docker compose up -d --build +``` + +Datenbank-Migrationen laufen automatisch beim Start. Daten im Volume `oikos_data` bleiben erhalten. + +--- + +## Entwicklung + +```bash +npm install +cp .env.example .env +# SESSION_SECRET setzen — DB_ENCRYPTION_KEY weglassen (kein SQLCipher lokal) +npm run dev # Server mit Auto-Reload +``` + +```bash +npm test # 146 Tests, 7 Suiten (In-Memory-SQLite, keine laufende App nötig) +``` --- ## Datensicherung -Die gesamte Datenbank liegt in einer einzigen Datei: - ```bash -# Backup -cp /data/oikos.db /backup/oikos-$(date +%Y%m%d).db +# Backup erstellen +docker run --rm \ + -v oikos_oikos_data:/data \ + -v $(pwd):/backup \ + alpine tar czf /backup/oikos-backup-$(date +%Y%m%d).tar.gz /data -# Docker-Volume sichern -docker run --rm -v oikos_data:/data -v $(pwd):/backup \ - alpine tar czf /backup/oikos-backup.tar.gz /data +# Backup wiederherstellen +docker compose down +docker run --rm \ + -v oikos_oikos_data:/data \ + -v $(pwd):/backup \ + alpine tar xzf /backup/oikos-backup-YYYYMMDD.tar.gz -C / +docker compose up -d ``` --- -## Tests ausführen +## Sicherheit -```bash -npm test -``` - -Die Tests verwenden In-Memory-SQLite und benötigen keine laufende App-Instanz. +- Sessions: `httpOnly`, `SameSite=Strict`, `Secure` in Produktion, 7 Tage TTL +- CSRF-Schutz via Double Submit Cookie auf allen schreibenden Requests +- Passwörter mit bcrypt (Cost Factor 12) gehasht +- Login-Rate-Limit: 5 Versuche/Minute +- API-Rate-Limit: 300 Requests/Minute pro IP +- Content Security Policy via Helmet +- Datenbank optional mit SQLCipher AES-256 verschlüsselt (im Docker-Container) +- Kein API-Endpoint ohne Session-Auth erreichbar (außer `/api/v1/auth/login`) --- diff --git a/public/index.html b/public/index.html index 1eef7f8..a3d2658 100644 --- a/public/index.html +++ b/public/index.html @@ -32,6 +32,7 @@ + diff --git a/public/pages/settings.js b/public/pages/settings.js index ebcac98..c773a94 100644 --- a/public/pages/settings.js +++ b/public/pages/settings.js @@ -1,25 +1,406 @@ /** - * Modul: Settings - * Zweck: Seite für das Settings-Modul + * Modul: Einstellungen (Settings) + * Zweck: Benutzerkonto, Passwort, Kalender-Sync, Familienmitglieder * Abhängigkeiten: /api.js */ -import { api } from '/api.js'; +import { api, auth } from '/api.js'; /** * @param {HTMLElement} container * @param {{ user: object }} context */ export async function render(container, { user }) { + // URL-Parameter auswerten (z.B. nach OAuth-Callback) + const params = new URLSearchParams(location.search); + const syncOk = params.get('sync_ok'); + const syncErr = params.get('sync_error'); + + // State für Familienmitglieder + Sync-Status + let users = []; + let googleStatus = { configured: false, connected: false, lastSync: null }; + let appleStatus = { configured: false, lastSync: null }; + + try { + const [usersRes, gStatus, aStatus] = await Promise.allSettled([ + user.role === 'admin' ? auth.getUsers() : Promise.resolve({ data: [] }), + api.get('/calendar/google/status'), + api.get('/calendar/apple/status'), + ]); + if (usersRes.status === 'fulfilled') users = usersRes.value.data ?? []; + if (gStatus.status === 'fulfilled') googleStatus = gStatus.value; + if (aStatus.status === 'fulfilled') appleStatus = aStatus.value; + } catch (_) { /* non-critical */ } + container.innerHTML = ` -
+
-
-
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

+ +
+ +
+ +
+

Passwort ändern

+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+ + +
+

Kalender-Synchronisation

+ + +
+
+ +
+
Google Calendar
+
+ ${googleStatus.connected + ? `Verbunden${googleStatus.lastSync ? ` · Zuletzt: ${formatDate(googleStatus.lastSync)}` : ''}` + : googleStatus.configured ? 'Nicht verbunden' : 'Nicht konfiguriert (fehlende .env-Variablen)'} +
+
+
+ ${googleStatus.configured ? ` +
+ ${googleStatus.connected ? ` + + ${user?.role === 'admin' ? `` : ''} + ` : ` + ${user?.role === 'admin' ? `Mit Google verbinden` : 'Nur Admin kann Google Calendar verbinden.'} + `} +
+ ` : ''} +
+ + +
+
+ +
+
Apple Calendar (iCloud)
+
+ ${appleStatus.configured + ? `Konfiguriert${appleStatus.lastSync ? ` · Zuletzt: ${formatDate(appleStatus.lastSync)}` : ''}` + : 'Nicht konfiguriert (APPLE_CALDAV_URL, APPLE_USERNAME, APPLE_APP_SPECIFIC_PASSWORD in .env setzen)'} +
+
+
+ ${appleStatus.configured ? ` +
+ +
+ ` : ''} +
+
+ + + ${user?.role === 'admin' ? ` +
+

Familienmitglieder

+
+
    + ${users.map(memberHtml).join('')} +
+ +
+ +
+

Neues Familienmitglied

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+
+ ` : ''} + + +
+ +
`; + + 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 };