# ICS-URL Subscription Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Allow any family member to subscribe to external ICS-URL calendars (e.g. public Google/Outlook feeds) with per-subscription color, visibility (private/shared), and automatic sync. **Architecture:** Four-commit sequence — (1) refactor ICS parser into a shared module, (2) DB migrations, (3) backend service + routes, (4) frontend + i18n. Events are stored in `calendar_events` with `external_source = 'ics'`. A new `ics_subscriptions` table holds metadata. RRULE events are pre-expanded into individual DB rows with synthetic UIDs to prevent double-expansion at query time. **Tech Stack:** Node.js/Express, better-sqlite3 (sync), `node-fetch` (already a dependency), `node:sqlite` for tests. No new npm packages. --- ## File Map | Action | Path | Responsibility | |--------|------|----------------| | Create | `server/services/ics-parser.js` | All ICS parsing + RRULE expansion | | Modify | `server/services/apple-calendar.js` | Import parseICS from ics-parser.js | | Modify | `server/db.js` | Migrations v10 + v11 | | Modify | `server/db-schema-test.js` | Mirror new schema for tests | | Create | `server/services/ics-subscription.js` | Fetch, sync, CRUD service | | Modify | `server/routes/calendar.js` | Subscription routes + visibility filter + user_modified | | Modify | `server/index.js` | Wire ICS sync into runSync() | | Modify | `public/pages/settings.js` | ICS subscription card UI | | Modify | `public/pages/calendar.js` | ICS event color + reset link | | Modify | `public/locales/de.json` | i18n keys | | Create | `test-ics-parser.js` | Parser unit tests | | Create | `test-ics-subscription.js` | Subscription DB/service tests | | Modify | `package.json` | Add test scripts | --- ## Task 1: Extract ICS parser into shared module **Files:** - Create: `server/services/ics-parser.js` - Create: `test-ics-parser.js` - Modify: `server/services/apple-calendar.js` > Pure refactor — no logic changes. All existing tests must pass before and after. - [ ] **Step 1: Create `server/services/ics-parser.js`** Create the file with the following content. The five functions (`unfoldLines`, `parseICS`, `formatICSDate`, `tzLocalToUTC`, `applyDuration`) are copied verbatim from `apple-calendar.js` lines 113–287 with one addition: a new top-level import of `nextOccurrence` from `./recurrence.js` and the new `expandRRULE` function: ```js import { nextOccurrence } from './recurrence.js'; function unfoldLines(ics) { return ics.replace(/\r?\n[ \t]/g, ''); } 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; const parseDTLine = (prop) => { const re = new RegExp(`^${prop}((?:;[^:]*)*):(.*)$`, 'im'); const m = block.match(re); if (!m) return { value: null, tzid: null }; const params = m[1]; const value = m[2].trim(); const tzMatch = params.match(/;TZID=([^;:]+)/i); return { value, tzid: tzMatch ? tzMatch[1].trim() : null }; }; const dtStartLine = parseDTLine('DTSTART'); const dtEndLine = parseDTLine('DTEND'); const dtStartRaw = dtStartLine.value; const dtEndRaw = dtEndLine.value; const allDay = /^DTSTART;VALUE=DATE:/im.test(block); const dtstart = dtStartRaw ? formatICSDate(dtStartRaw, allDay, dtStartLine.tzid) : null; let dtend = dtEndRaw ? formatICSDate(dtEndRaw, allDay, dtEndLine.tzid) : null; if (allDay && dtend) { const d = new Date(dtend + 'T00:00:00'); d.setDate(d.getDate() - 1); dtend = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } if (!dtend && dtstart) { const durMatch = /^DURATION(?:;[^:]*)?:(.*)$/im.exec(block); if (durMatch) dtend = applyDuration(dtstart, durMatch[1].trim(), allDay); } if (!uid || !dtstart) continue; events.push({ uid, summary, description, location, dtstart, dtend, rrule, allDay }); } return events; } function tzLocalToUTC(localStr, tzid) { try { const fakeUTC = new Date(localStr + 'Z'); const parts = new Intl.DateTimeFormat('en-US', { timeZone: tzid, year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric', second: 'numeric', hour12: false, }).formatToParts(fakeUTC); const get = (type) => { const part = parts.find(p => p.type === type); const v = part ? part.value : '0'; return v === '24' ? 0 : parseInt(v, 10); }; const tzDisplayedAsUTC = Date.UTC( get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second') ); const offsetMs = fakeUTC.getTime() - tzDisplayedAsUTC; return new Date(fakeUTC.getTime() + offsetMs).toISOString().replace('.000Z', 'Z'); } catch { return localStr; } } function formatICSDate(val, allDay, tzid) { if (allDay || /^\d{8}$/.test(val)) { return `${val.slice(0, 4)}-${val.slice(4, 6)}-${val.slice(6, 8)}`; } const y = val.slice(0, 4), mo = val.slice(4, 6), d = val.slice(6, 8); const h = val.slice(9, 11), mi = val.slice(11, 13), s = val.slice(13, 15) || '00'; if (val.endsWith('Z')) return `${y}-${mo}-${d}T${h}:${mi}:${s}Z`; if (tzid) return tzLocalToUTC(`${y}-${mo}-${d}T${h}:${mi}:${s}`, tzid); return `${y}-${mo}-${d}T${h}:${mi}:${s}`; } function applyDuration(dtstart, dur, allDay) { const m = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/.exec(dur); if (!m) return null; const weeks = parseInt(m[1] || '0', 10), days = parseInt(m[2] || '0', 10); const hours = parseInt(m[3] || '0', 10), mins = parseInt(m[4] || '0', 10); const secs = parseInt(m[5] || '0', 10); const base = new Date(dtstart.includes('T') ? dtstart : dtstart + 'T00:00:00'); base.setDate(base.getDate() + weeks * 7 + days); base.setHours(base.getHours() + hours, base.getMinutes() + mins, base.getSeconds() + secs); if (allDay) { base.setDate(base.getDate() - 1); return `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}-${String(base.getDate()).padStart(2, '0')}`; } return base.toISOString().replace('.000Z', 'Z'); } /** * Expands a VEVENT with RRULE into individual occurrences within [windowStart, windowEnd]. * Returns flat event objects with recurrence_rule: null and synthetic UIDs ({uid}__{YYYY-MM-DD}). */ function expandRRULE(vevent, windowStart, windowEnd) { if (!vevent.rrule) return []; const results = []; const startDate = vevent.dtstart.slice(0, 10); const timeSuffix = vevent.allDay ? '' : (vevent.dtstart.slice(10) || ''); let durationMs = null; if (vevent.dtend) { const s = new Date(vevent.allDay ? vevent.dtstart + 'T00:00:00Z' : vevent.dtstart); const e = new Date(vevent.allDay ? vevent.dtend + 'T00:00:00Z' : vevent.dtend); if (!isNaN(s) && !isNaN(e)) durationMs = e - s; } let current = startDate, iterations = 0; const MAX_ITER = 1500; while (current <= windowEnd && iterations < MAX_ITER) { iterations++; if (current >= windowStart) { const occStart = current + timeSuffix; let occEnd = null; if (durationMs !== null) { if (vevent.allDay) { const d = new Date(current + 'T00:00:00Z'); d.setUTCMilliseconds(d.getUTCMilliseconds() + durationMs); occEnd = d.toISOString().slice(0, 10); } else { occEnd = new Date(new Date(occStart).getTime() + durationMs) .toISOString().replace('.000Z', 'Z'); } } results.push({ uid: `${vevent.uid}__${current}`, summary: vevent.summary, description: vevent.description, location: vevent.location, dtstart: occStart, dtend: occEnd, rrule: null, allDay: vevent.allDay, }); } const next = nextOccurrence(current, vevent.rrule); if (!next || next <= current) break; current = next; } return results; } export { unfoldLines, parseICS, formatICSDate, tzLocalToUTC, applyDuration, expandRRULE }; ``` - [ ] **Step 2: Update `server/services/apple-calendar.js`** Remove the function bodies for `unfoldLines`, `parseICS`, `formatICSDate`, `tzLocalToUTC`, and `applyDuration` (the entire block from line 113 to 287). Replace with a single import line at the top of the file (after the existing imports): ```js import { unfoldLines, parseICS, formatICSDate, tzLocalToUTC, applyDuration } from './ics-parser.js'; ``` All other functions (`buildICS`, `escapeICS`, `sync`, `getStatus`, etc.) remain unchanged. - [ ] **Step 3: Create `test-ics-parser.js`** ```js import { unfoldLines, parseICS, expandRRULE } from './server/services/ics-parser.js'; let passed = 0, failed = 0; function test(name, fn) { try { fn(); console.log(` ✓ ${name}`); passed++; } catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; } } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } console.log('\n[ICS-Parser-Test]\n'); test('unfoldLines entfaltet Zeilenfortsetzungen', () => { const result = unfoldLines('SUMMARY:Hallo\r\n Welt'); assert(result === 'SUMMARY:Hallo Welt', `got: ${result}`); }); test('parseICS: einfaches Ganztags-Event', () => { const ics = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:test-1@x\r\nSUMMARY:Geburtstag\r\nDTSTART;VALUE=DATE:20260501\r\nDTEND;VALUE=DATE:20260502\r\nEND:VEVENT\r\nEND:VCALENDAR'; const events = parseICS(ics); assert(events.length === 1, `expected 1, got ${events.length}`); assert(events[0].uid === 'test-1@x', 'uid'); assert(events[0].dtstart === '2026-05-01', `dtstart: ${events[0].dtstart}`); assert(events[0].dtend === '2026-05-01', `dtend: ${events[0].dtend}`); assert(events[0].allDay === true, 'allDay'); }); test('parseICS: Event ohne UID wird übersprungen', () => { const ics = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Ohne UID\r\nDTSTART:20260601T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR'; assert(parseICS(ics).length === 0, 'should skip event without UID'); }); test('parseICS: UTC datetime', () => { const ics = 'BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:utc@x\r\nSUMMARY:Meeting\r\nDTSTART:20260615T140000Z\r\nDTEND:20260615T150000Z\r\nEND:VEVENT\r\nEND:VCALENDAR'; const [ev] = parseICS(ics); assert(ev.dtstart === '2026-06-15T14:00:00Z', `dtstart: ${ev.dtstart}`); assert(ev.allDay === false, 'allDay'); }); test('expandRRULE: WEEKLY 3-Wochen-Fenster', () => { const vevent = { uid: 'weekly@x', summary: 'Wöchentlich', description: null, location: null, dtstart: '2026-04-13', dtend: '2026-04-13', rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO', allDay: true, }; const occ = expandRRULE(vevent, '2026-04-13', '2026-05-04'); assert(occ.length >= 3, `expected >=3, got ${occ.length}`); assert(occ[0].uid === 'weekly@x__2026-04-13', `uid: ${occ[0].uid}`); assert(occ[0].rrule === null, 'expanded events have null rrule'); }); test('expandRRULE: null rrule → leeres Array', () => { const v = { uid: 'x', summary: 'x', description: null, location: null, dtstart: '2026-04-20', dtend: null, rrule: null, allDay: true }; assert(expandRRULE(v, '2026-01-01', '2026-12-31').length === 0); }); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); ``` - [ ] **Step 4: Add test script to `package.json`** Add `"test:ics-parser": "node test-ics-parser.js"` to scripts. Append `&& node test-ics-parser.js` to the end of the `"test"` script. - [ ] **Step 5: Run parser tests** ```bash cd oikos && node test-ics-parser.js ``` Expected: all `✓`, exit 0. - [ ] **Step 6: Run full test suite to verify no regression** ```bash cd oikos && npm test ``` Expected: all tests pass. - [ ] **Step 7: Commit** ```bash git add server/services/ics-parser.js server/services/apple-calendar.js test-ics-parser.js package.json git commit -m "refactor(calendar): extract ICS parser into shared ics-parser.js module" ``` --- ## Task 2: Database migrations **Files:** - Modify: `server/db.js` - Modify: `server/db-schema-test.js` - Create: `test-ics-subscription.js` - [ ] **Step 1: Append migrations v10 and v11 to `server/db.js`** Find the `MIGRATIONS` array. After the last entry (v9), append: ```js { version: 10, description: 'ICS-Abonnements Tabelle', up: ` CREATE TABLE IF NOT EXISTS ics_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL, color TEXT NOT NULL DEFAULT '#6366f1', shared INTEGER NOT NULL DEFAULT 0, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, etag TEXT, last_modified TEXT, last_sync TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); `, }, { version: 11, description: 'calendar_events: external_source ICS, subscription_id, user_modified', up: ` CREATE TABLE calendar_events_new ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, start_datetime TEXT NOT NULL, end_datetime TEXT, all_day INTEGER NOT NULL DEFAULT 0, location TEXT, color TEXT NOT NULL DEFAULT '#007AFF', assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, external_calendar_id TEXT, external_source TEXT NOT NULL DEFAULT 'local' CHECK(external_source IN ('local', 'google', 'apple', 'ics')), recurrence_rule TEXT, subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADE, user_modified INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); INSERT INTO calendar_events_new (id, title, description, start_datetime, end_datetime, all_day, location, color, assigned_to, created_by, external_calendar_id, external_source, recurrence_rule, subscription_id, user_modified, created_at, updated_at) SELECT id, title, description, start_datetime, end_datetime, all_day, location, color, assigned_to, created_by, external_calendar_id, external_source, recurrence_rule, NULL, 0, created_at, updated_at FROM calendar_events; DROP TRIGGER IF EXISTS trg_calendar_events_updated_at; DROP TABLE calendar_events; ALTER TABLE calendar_events_new RENAME TO calendar_events; CREATE TRIGGER trg_calendar_events_updated_at AFTER UPDATE ON calendar_events FOR EACH ROW BEGIN UPDATE calendar_events SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END; CREATE INDEX IF NOT EXISTS idx_calendar_start ON calendar_events(start_datetime); CREATE INDEX IF NOT EXISTS idx_calendar_assigned ON calendar_events(assigned_to); CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id); CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id); CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id) WHERE subscription_id IS NOT NULL; `, }, ``` - [ ] **Step 2: Update `server/db-schema-test.js`** Add entries for keys `10` and `11` to the `MIGRATIONS_SQL` object at the bottom: ```js 10: ` CREATE TABLE IF NOT EXISTS ics_subscriptions ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, url TEXT NOT NULL, color TEXT NOT NULL DEFAULT '#6366f1', shared INTEGER NOT NULL DEFAULT 0, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, etag TEXT, last_modified TEXT, last_sync TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); `, 11: ` CREATE TABLE calendar_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, description TEXT, start_datetime TEXT NOT NULL, end_datetime TEXT, all_day INTEGER NOT NULL DEFAULT 0, location TEXT, color TEXT NOT NULL DEFAULT '#007AFF', assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL, created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, external_calendar_id TEXT, external_source TEXT NOT NULL DEFAULT 'local' CHECK(external_source IN ('local', 'google', 'apple', 'ics')), recurrence_rule TEXT, subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADE, user_modified INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) ); CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id) WHERE subscription_id IS NOT NULL; `, ``` - [ ] **Step 3: Create `test-ics-subscription.js`** ```js import { DatabaseSync } from 'node:sqlite'; import { MIGRATIONS_SQL } from './server/db-schema-test.js'; let passed = 0, failed = 0; function test(name, fn) { try { fn(); console.log(` ✓ ${name}`); passed++; } catch (err) { console.error(` ✗ ${name}: ${err.message}`); failed++; } } function assert(cond, msg) { if (!cond) throw new Error(msg || 'Assertion failed'); } const db = new DatabaseSync(':memory:'); db.exec('PRAGMA foreign_keys = ON;'); // Minimal users table (migrations_sql[10] and [11] reference users) db.exec(`CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, display_name TEXT NOT NULL, password_hash TEXT NOT NULL, avatar_color TEXT NOT NULL DEFAULT '#007AFF', role TEXT NOT NULL DEFAULT 'member', created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) );`); db.exec(MIGRATIONS_SQL[10]); db.exec(MIGRATIONS_SQL[11]); const uid1 = db.prepare(`INSERT INTO users (username,display_name,password_hash,role) VALUES ('admin','Admin','x','admin')`).run().lastInsertRowid; const uid2 = db.prepare(`INSERT INTO users (username,display_name,password_hash) VALUES ('maria','Maria','x')`).run().lastInsertRowid; console.log('\n[ICS-Subscription-Test] DB-Schema\n'); let subId; test('Abonnement anlegen', () => { subId = db.prepare(`INSERT INTO ics_subscriptions (name,url,color,shared,created_by) VALUES ('Feiertage','https://x.com/de.ics','#FF3B30',0,?)`).run(uid1).lastInsertRowid; assert(subId > 0); }); test('Geteiltes Abonnement anlegen', () => { const id = db.prepare(`INSERT INTO ics_subscriptions (name,url,color,shared,created_by) VALUES ('Schulferien','https://x.com/school.ics','#34C759',1,?)`).run(uid2).lastInsertRowid; assert(id > 0); }); test('ICS-Event einfügen (external_source=ics)', () => { const id = db.prepare(`INSERT INTO calendar_events (title,start_datetime,all_day,external_source,external_calendar_id,subscription_id,created_by) VALUES ('Neujahr','2026-01-01',1,'ics','neujahr@test',?,?)`).run(subId, uid1).lastInsertRowid; assert(id > 0); }); test('Doppelte UID in gleicher Subscription verletzt UNIQUE', () => { let threw = false; try { db.prepare(`INSERT INTO calendar_events (title,start_datetime,all_day,external_source,external_calendar_id,subscription_id,created_by) VALUES ('Dup','2026-01-01',1,'ics','neujahr@test',?,?)`).run(subId, uid1); } catch { threw = true; } assert(threw, 'UNIQUE should fire'); }); test('Gleiche UID in anderer Subscription erlaubt', () => { const sub2 = db.prepare(`INSERT INTO ics_subscriptions (name,url,color,created_by) VALUES ('Sub2','https://b.com/b.ics','#000',?)`).run(uid1).lastInsertRowid; const id = db.prepare(`INSERT INTO calendar_events (title,start_datetime,all_day,external_source,external_calendar_id,subscription_id,created_by) VALUES ('Neujahr2','2026-01-01',1,'ics','neujahr@test',?,?)`).run(sub2, uid1).lastInsertRowid; assert(id > 0); }); test('user_modified Default ist 0', () => { const ev = db.prepare(`SELECT user_modified FROM calendar_events WHERE subscription_id = ?`).get(subId); assert(ev.user_modified === 0); }); test('user_modified auf 1 setzen', () => { db.prepare(`UPDATE calendar_events SET user_modified = 1 WHERE subscription_id = ?`).run(subId); assert(db.prepare(`SELECT user_modified FROM calendar_events WHERE subscription_id = ?`).get(subId).user_modified === 1); }); test('Sichtbarkeitsfilter: privates Abo unsichtbar für anderen User', () => { const rows = db.prepare(` SELECT e.id FROM calendar_events e JOIN ics_subscriptions s ON s.id = e.subscription_id WHERE e.external_source = 'ics' AND (s.shared = 1 OR s.created_by = ?) `).all(uid2); // uid2 sieht nur geteilte Events (Schulferien hat kein Event yet), nicht Feiertage (privat von uid1) const ids = rows.map(r => r.id); assert(!ids.includes(1), 'privates Abo nicht sichtbar für uid2'); }); test('Cascade delete: Subscription löschen entfernt Events', () => { const tmp = db.prepare(`INSERT INTO ics_subscriptions (name,url,color,created_by) VALUES ('Tmp','https://t.com/t.ics','#999',?)`).run(uid1).lastInsertRowid; db.prepare(`INSERT INTO calendar_events (title,start_datetime,all_day,external_source,external_calendar_id,subscription_id,created_by) VALUES ('TmpEv','2026-06-01',1,'ics','tmp@test',?,?)`).run(tmp, uid1); db.prepare(`DELETE FROM ics_subscriptions WHERE id = ?`).run(tmp); assert(db.prepare(`SELECT count(*) as c FROM calendar_events WHERE subscription_id = ?`).get(tmp).c === 0, 'cascade failed'); }); test('external_source CHECK blockiert ungültige Werte', () => { let threw = false; try { db.prepare(`INSERT INTO calendar_events (title,start_datetime,external_source,created_by) VALUES ('Bad','2026-01-01','invalid',?)`).run(uid1); } catch { threw = true; } assert(threw, 'CHECK should reject invalid external_source'); }); console.log(`\n${passed} passed, ${failed} failed`); if (failed > 0) process.exit(1); ``` - [ ] **Step 4: Add test scripts to `package.json`** Add `"test:ics-sub": "node --experimental-sqlite test-ics-subscription.js"` to scripts. Append `&& node --experimental-sqlite test-ics-subscription.js` to the `"test"` script. - [ ] **Step 5: Run subscription DB tests** ```bash cd oikos && node --experimental-sqlite test-ics-subscription.js ``` Expected: all `✓`, exit 0. - [ ] **Step 6: Run full test suite** ```bash cd oikos && npm test ``` Expected: all tests pass. - [ ] **Step 7: Commit** ```bash git add server/db.js server/db-schema-test.js test-ics-subscription.js package.json git commit -m "feat(calendar): add ics_subscriptions table and calendar_events columns (migrations v10-v11)" ``` --- ## Task 3: ICS subscription service **Files:** - Create: `server/services/ics-subscription.js` - [ ] **Step 1: Create `server/services/ics-subscription.js`** ```js import dns from 'node:dns/promises'; import fetch from 'node-fetch'; import { createLogger } from '../logger.js'; import * as db from '../db.js'; import { parseICS, expandRRULE } from './ics-parser.js'; const log = createLogger('ICS'); const SYNC_WINDOW_PAST_MONTHS = 6; const SYNC_WINDOW_FUTURE_MONTHS = 12; const MAX_RESPONSE_BYTES = 10 * 1024 * 1024; const FETCH_TIMEOUT_MS = 15_000; const PRIVATE_RANGES = [ /^127\./, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^169\.254\./, /^::1$/, /^fc/i, /^fe[89ab]/i, ]; const syncingNow = new Set(); function normalizeUrl(raw) { const url = new URL(raw.replace(/^webcal:\/\//i, 'https://')); if (url.protocol !== 'https:') throw new Error('Nur https:// und webcal:// URLs sind erlaubt.'); return url.href; } async function checkSSRF(urlStr) { const hostname = new URL(urlStr).hostname; const v4 = await dns.resolve4(hostname).catch(() => []); const v6 = await dns.resolve6(hostname).catch(() => []); for (const addr of [...v4, ...v6]) { if (PRIVATE_RANGES.some((re) => re.test(addr))) { throw new Error(`URL löst auf eine private IP-Adresse auf: ${addr}`); } } } async function fetchAndParse(urlRaw, etag, lastModified) { const url = normalizeUrl(urlRaw); await checkSSRF(url); const headers = {}; if (etag) headers['If-None-Match'] = etag; if (lastModified) headers['If-Modified-Since'] = lastModified; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); let res; try { res = await fetch(url, { headers, signal: controller.signal }); } finally { clearTimeout(timer); } if (res.status === 304) return { notModified: true }; if (!res.ok) throw new Error(`HTTP ${res.status}`); const cl = parseInt(res.headers.get('content-length') || '0', 10); if (cl > MAX_RESPONSE_BYTES) throw new Error('ICS-Datei überschreitet 10 MB Limit.'); let body = '', received = 0; for await (const chunk of res.body) { received += chunk.length; if (received > MAX_RESPONSE_BYTES) throw new Error('ICS-Datei überschreitet 10 MB Limit.'); body += chunk.toString(); } return { events: parseICS(body), newEtag: res.headers.get('etag') || null, newLastModified: res.headers.get('last-modified') || null, notModified: false, }; } function syncWindow() { const now = new Date(); const past = new Date(now); past.setMonth(past.getMonth() - SYNC_WINDOW_PAST_MONTHS); const future = new Date(now); future.setMonth(future.getMonth() + SYNC_WINDOW_FUTURE_MONTHS); return { windowStart: past.toISOString().slice(0, 10), windowEnd: future.toISOString().slice(0, 10) }; } async function syncOne(sub) { if (syncingNow.has(sub.id)) { log.info(`Abonnement ${sub.id} wird bereits synchronisiert - übersprungen.`); return; } syncingNow.add(sub.id); try { let result; try { result = await fetchAndParse(sub.url, sub.etag, sub.last_modified); } catch (err) { log.warn(`Abonnement ${sub.id} (${sub.name}): Fetch fehlgeschlagen - ${err.message}`); return; } if (result.notModified) { db.get().prepare(`UPDATE ics_subscriptions SET last_sync = ? WHERE id = ?`) .run(new Date().toISOString(), sub.id); return; } const { events, newEtag, newLastModified } = result; const { windowStart, windowEnd } = syncWindow(); const owner = db.get().prepare('SELECT id FROM users ORDER BY id ASC LIMIT 1').get(); const createdBy = sub.created_by ?? owner?.id; if (!createdBy) { log.warn('Kein User gefunden.'); return; } const flatEvents = []; for (const ev of events) { if (ev.rrule) { flatEvents.push(...expandRRULE(ev, windowStart, windowEnd)); } else if (ev.dtstart >= windowStart && ev.dtstart <= windowEnd) { flatEvents.push(ev); } } const seenUids = new Set(flatEvents.map((e) => e.uid)); const upsert = db.get().prepare(` INSERT INTO calendar_events (title, description, start_datetime, end_datetime, all_day, location, color, external_calendar_id, external_source, subscription_id, recurrence_rule, user_modified, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'ics', ?, ?, 0, ?) ON CONFLICT(subscription_id, 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 WHERE user_modified = 0 `); const deleteStale = db.get().prepare(` DELETE FROM calendar_events WHERE subscription_id = ? AND external_calendar_id NOT IN (SELECT value FROM json_each(?)) AND user_modified = 0 `); db.get().transaction(() => { for (const ev of flatEvents) { try { upsert.run(ev.summary, ev.description, ev.dtstart, ev.dtend, ev.allDay ? 1 : 0, ev.location, sub.color, ev.uid, sub.id, ev.rrule, createdBy); } catch (err) { log.error(`Upsert UID ${ev.uid}: ${err.message}`); } } deleteStale.run(sub.id, JSON.stringify([...seenUids])); db.get().prepare(`UPDATE ics_subscriptions SET last_sync = ?, etag = ?, last_modified = ? WHERE id = ?`) .run(new Date().toISOString(), newEtag, newLastModified, sub.id); })(); log.info(`Abonnement ${sub.id} (${sub.name}): ${flatEvents.length} Events synchronisiert.`); } finally { syncingNow.delete(sub.id); } } async function sync(subscriptionId) { const subs = subscriptionId ? db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').all(subscriptionId) : db.get().prepare('SELECT * FROM ics_subscriptions').all(); for (const sub of subs) await syncOne(sub); } function getAll(userId) { return db.get().prepare(` SELECT * FROM ics_subscriptions WHERE shared = 1 OR created_by = ? ORDER BY name ASC `).all(userId); } async function create(userId, { name, url, color, shared }) { const normalizedUrl = normalizeUrl(url); await checkSSRF(normalizedUrl); const subId = db.get().prepare( `INSERT INTO ics_subscriptions (name,url,color,shared,created_by) VALUES (?,?,?,?,?)` ).run(name, normalizedUrl, color, shared ? 1 : 0, userId).lastInsertRowid; const newSub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); let syncError = null; try { await syncOne(newSub); } catch (err) { syncError = err.message; } return { sub: newSub, syncError }; } function update(userId, subId, fields, isAdmin) { const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); if (!sub) return null; if (!isAdmin && sub.created_by !== userId) throw new Error('Nicht autorisiert.'); const name = fields.name !== undefined ? fields.name : sub.name; const color = fields.color !== undefined ? fields.color : sub.color; const shared = fields.shared !== undefined ? (fields.shared ? 1 : 0) : sub.shared; db.get().prepare(`UPDATE ics_subscriptions SET name = ?, color = ?, shared = ? WHERE id = ?`) .run(name, color, shared, subId); return db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); } function remove(userId, subId, isAdmin) { const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); if (!sub) return false; if (!isAdmin && sub.created_by !== userId) throw new Error('Nicht autorisiert.'); db.get().prepare('DELETE FROM ics_subscriptions WHERE id = ?').run(subId); return true; } export { sync, getAll, create, update, remove, fetchAndParse }; ``` - [ ] **Step 2: Run full test suite** ```bash cd oikos && npm test ``` Expected: all tests pass. --- ## Task 4: Routes + sync integration **Files:** - Modify: `server/routes/calendar.js` - Modify: `server/index.js` - [ ] **Step 1: Add import to `server/routes/calendar.js`** At the top of the file, after the existing imports, add: ```js import * as icsSubscription from '../services/ics-subscription.js'; ``` - [ ] **Step 2: Update `VALID_SOURCES` in `calendar.js`** Change line 21 from: ```js const VALID_SOURCES = ['local', 'google', 'apple']; ``` to: ```js const VALID_SOURCES = ['local', 'google', 'apple', 'ics']; ``` - [ ] **Step 3: Add ICS visibility filter to GET `/` in `calendar.js`** Inside the `router.get('/', ...)` handler, locate the `let sql = \`...\`` block. After the last `params.push(...)` call and before `sql += ' ORDER BY ...'`, add: ```js const userId = req.session.userId; sql += ` AND ( e.external_source != 'ics' OR e.subscription_id IN ( SELECT id FROM ics_subscriptions WHERE shared = 1 OR created_by = ${parseInt(userId, 10)} ) )`; ``` Apply the same filter to the `/upcoming` route's SQL query. - [ ] **Step 4: Add `user_modified = 1` on ICS event edit** Find the existing `PATCH /:id` route handler. After the main UPDATE statement runs and before `res.json(...)`, add: ```js const updated = db.get().prepare('SELECT external_source FROM calendar_events WHERE id = ?').get(eventId); if (updated?.external_source === 'ics') { db.get().prepare('UPDATE calendar_events SET user_modified = 1 WHERE id = ?').run(eventId); } ``` - [ ] **Step 5: Add reset endpoint — before the `export default router` line** ```js router.patch('/:id/reset', (req, res) => { try { const eventId = parseInt(req.params.id, 10); const event = db.get().prepare(` SELECT e.*, s.created_by AS sub_owner FROM calendar_events e LEFT JOIN ics_subscriptions s ON s.id = e.subscription_id WHERE e.id = ? `).get(eventId); if (!event) return res.status(404).json({ error: 'Nicht gefunden.', code: 404 }); if (event.external_source !== 'ics') return res.status(400).json({ error: 'Nur ICS-Events können zurückgesetzt werden.', code: 400 }); const isAdmin = req.session.userRole === 'admin'; const canReset = isAdmin || event.created_by === req.session.userId || event.sub_owner === req.session.userId; if (!canReset) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 }); db.get().prepare('UPDATE calendar_events SET user_modified = 0 WHERE id = ?').run(eventId); res.json({ data: { reset: true } }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Interner Fehler', code: 500 }); } }); ``` - [ ] **Step 6: Add subscription CRUD routes — before `export default router`** ```js router.get('/subscriptions', (req, res) => { try { res.json({ data: icsSubscription.getAll(req.session.userId) }); } catch (err) { log.error('', err); res.status(500).json({ error: 'Interner Fehler', code: 500 }); } }); router.post('/subscriptions', async (req, res) => { try { const { name: nameRaw, url: urlRaw, color: colorRaw, shared } = req.body; const nameRes = str(nameRaw, 'name', { max: 100 }); const colorRes = color(colorRaw, 'color'); const errors = collectErrors([nameRes, colorRes]); if (!urlRaw) errors.push('url ist erforderlich.'); else { try { new URL(urlRaw.replace(/^webcal:\/\//i, 'https://')); } catch { errors.push('url ist keine gültige URL.'); } if (!/^(https?|webcal):\/\//i.test(urlRaw)) errors.push('Nur https:// und webcal:// erlaubt.'); } if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const result = await icsSubscription.create(req.session.userId, { name: nameRes.value, url: urlRaw, color: colorRes.value || '#6366f1', shared: !!shared, }); res.status(201).json({ data: result.sub, syncError: result.syncError }); } catch (err) { log.error('', err); const code = err.message.includes('private IP') ? 400 : 500; res.status(code).json({ error: err.message, code }); } }); router.patch('/subscriptions/:id', (req, res) => { try { const subId = parseInt(req.params.id, 10); const isAdmin = req.session.userRole === 'admin'; const errors = []; if (req.body.name !== undefined) { const r = str(req.body.name, 'name', { max: 100 }); if (r.error) errors.push(r.error); } if (req.body.color !== undefined) { const r = color(req.body.color, 'color'); if (r.error) errors.push(r.error); } if (errors.length) return res.status(400).json({ error: errors.join(' '), code: 400 }); const updated = icsSubscription.update(req.session.userId, subId, req.body, isAdmin); if (!updated) return res.status(404).json({ error: 'Nicht gefunden.', code: 404 }); res.json({ data: updated }); } catch (err) { log.error('', err); if (err.message === 'Nicht autorisiert.') return res.status(403).json({ error: err.message, code: 403 }); res.status(500).json({ error: 'Interner Fehler', code: 500 }); } }); router.delete('/subscriptions/:id', (req, res) => { try { const subId = parseInt(req.params.id, 10); const isAdmin = req.session.userRole === 'admin'; const ok = icsSubscription.remove(req.session.userId, subId, isAdmin); if (!ok) return res.status(404).json({ error: 'Nicht gefunden.', code: 404 }); res.json({ ok: true }); } catch (err) { log.error('', err); if (err.message === 'Nicht autorisiert.') return res.status(403).json({ error: err.message, code: 403 }); res.status(500).json({ error: 'Interner Fehler', code: 500 }); } }); router.post('/subscriptions/:id/sync', async (req, res) => { try { const subId = parseInt(req.params.id, 10); const isAdmin = req.session.userRole === 'admin'; const sub = db.get().prepare('SELECT * FROM ics_subscriptions WHERE id = ?').get(subId); if (!sub) return res.status(404).json({ error: 'Nicht gefunden.', code: 404 }); if (!isAdmin && sub.created_by !== req.session.userId) return res.status(403).json({ error: 'Nicht autorisiert.', code: 403 }); await icsSubscription.sync(subId); const row = db.get().prepare('SELECT last_sync FROM ics_subscriptions WHERE id = ?').get(subId); res.json({ ok: true, lastSync: row.last_sync }); } catch (err) { log.error('', err); res.status(500).json({ error: err.message, code: 500 }); } }); ``` **Important:** These subscription routes must be registered BEFORE the `/:id` route to avoid Express matching `subscriptions` as an `:id` parameter. Place them above the `router.get('/:id', ...)` handler. - [ ] **Step 7: Wire ICS sync into `server/index.js`** Add import after existing service imports: ```js import * as icsSubscription from './services/ics-subscription.js'; ``` In `runSync()`, after the Apple Calendar block, add: ```js icsSubscription.sync().catch((e) => logSync.error('ICS Fehler:', e.message)); ``` - [ ] **Step 8: Run full test suite** ```bash cd oikos && npm test ``` Expected: all tests pass. - [ ] **Step 9: Commit** ```bash git add server/services/ics-subscription.js server/routes/calendar.js server/index.js git commit -m "feat(calendar): add ICS subscription service, routes, and sync integration" ``` --- ## Task 5: i18n keys **Files:** - Modify: `public/locales/de.json` - [ ] **Step 1: Add keys to the `"settings"` object** Open `public/locales/de.json`. Inside the `"settings"` object, add an `"ics"` sub-object: ```json "ics": { "title": "ICS-Abonnements", "noSubscriptions": "Noch keine Abonnements.", "add": "Abonnement hinzufügen", "cancel": "Abbrechen", "save": "Speichern", "syncNow": "Jetzt synchronisieren", "syncing": "Synchronisiert...", "edit": "Bearbeiten", "delete": "Löschen", "confirmDelete": "Abonnement und alle zugehörigen Events löschen?", "badgePrivate": "Privat", "badgeShared": "Geteilt", "lastSync": "Zuletzt synchronisiert:", "neverSynced": "Noch nicht synchronisiert", "syncError": "Erster Sync fehlgeschlagen:", "form": { "urlLabel": "ICS-URL", "urlPlaceholder": "https://... oder webcal://...", "nameLabel": "Name", "namePlaceholder": "z.B. Feiertage Deutschland", "colorLabel": "Farbe", "sharedLabel": "Für alle Familienmitglieder sichtbar" } } ``` - [ ] **Step 2: Add key to the `"calendar"` object** ```json "icsReset": "Auf Original zurücksetzen" ``` - [ ] **Step 3: Validate JSON** ```bash cd oikos && node -e "JSON.parse(require('fs').readFileSync('public/locales/de.json','utf8')); console.log('JSON valid')" ``` Expected: `JSON valid` --- ## Task 6: Settings page UI **Files:** - Modify: `public/pages/settings.js` - [ ] **Step 1: Add subscriptions to the data fetch in `render()`** Find the `Promise.allSettled([...])` call (around line 63). Add `api.get('/calendar/subscriptions')` as the last entry. After the `allSettled` destructuring, add: ```js let icsSubscriptions = []; // Change the destructuring to capture the 6th result: const [usersRes, gStatus, aStatus, prefsRes, catsRes, icsRes] = await Promise.allSettled([...]); if (icsRes.status === 'fulfilled') icsSubscriptions = icsRes.value.data ?? []; ``` - [ ] **Step 2: Update `bindEvents()` signature** Change the function signature from `bindEvents(container, user, categories)` to `bindEvents(container, user, categories, icsSubscriptions)`. Update the call at the end of `render()` to `bindEvents(container, user, categories, icsSubscriptions)`. - [ ] **Step 3: Add the ICS card to the calendar tab HTML in the template string** Locate the closing `` of the calendar sync section (after the Apple Calendar card). Before that closing tag, add: ```html