feat: Schritte 14–15 — Google Calendar OAuth + Apple CalDAV Sync + Settings-Seite
- server/services/google-calendar.js: OAuth 2.0, bidirektionaler Sync via Google Calendar API v3, inkrementeller syncToken, 410-Fallback auf Vollsync - server/services/apple-calendar.js: CalDAV via tsdav (dynamic ESM import), minimaler ICS-Parser + ICS-Builder, bidirektionaler Sync - server/routes/calendar.js: 7 neue Sync-Routen (google/auth, google/callback, google/sync, google/status, google/disconnect, apple/status, apple/sync) - server/db.js: Migration 2 — sync_config Tabelle + idx_calendar_external_id - server/db-schema-test.js: MIGRATIONS_SQL[2] für Tests synchronisiert - server/auth.js: PATCH /me/password Endpoint - server/index.js: Auto-Sync-Scheduler (setInterval, SYNC_INTERVAL_MINUTES) - public/pages/settings.js: vollständige Settings-Seite (Konto, Passwort, Kalender-Sync-Status + Aktionen, Familienmitglieder-Verwaltung) - public/styles/settings.css: neue Stylesheet-Datei - public/index.html + public/sw.js: settings.css eingebunden und gecacht - .env.example: SYNC_INTERVAL_MINUTES ergänzt - README.md: vollständige Setup-Anleitung, Google/Apple-Sync-Dokumentation, modernes GitHub-Layout mit Badges und aufklappbaren Abschnitten Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+13
-2
@@ -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);
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
+28
-1
@@ -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;
|
||||
|
||||
+126
-3
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user