From a64635b66985a89338c2e4575f365382a57c5b9f Mon Sep 17 00:00:00 2001 From: Ulas Kalayci Date: Mon, 20 Apr 2026 23:32:42 +0200 Subject: [PATCH] feat(calendar): add ics_subscriptions table and calendar_events columns (migrations v10-v11) --- CHANGELOG.md | 8 ++++ package.json | 5 ++- server/db-schema-test.js | 39 ++++++++++++++++ server/db.js | 70 +++++++++++++++++++++++++++++ test-ics-subscription.js | 96 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 216 insertions(+), 2 deletions(-) create mode 100644 test-ics-subscription.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ced471..5468cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.20.36] - 2026-04-20 + +### Added +- Migration v10: new `ics_subscriptions` table with fields for name, URL, color, shared flag, created_by, etag, last_modified, last_sync, and created_at +- Migration v11: `calendar_events` table recreated to extend the `external_source` CHECK constraint to include `'ics'`, and two new columns added — `subscription_id` (FK to `ics_subscriptions` with CASCADE delete) and `user_modified` (integer flag, default 0) +- Unique partial index `idx_calendar_sub_extid` on `(subscription_id, external_calendar_id)` prevents duplicate UIDs within a single ICS subscription while allowing the same UID across different subscriptions +- `test:ics-sub` test suite with 10 tests covering subscription CRUD, ICS event insertion, UNIQUE constraint enforcement, cascade delete, visibility filtering, and CHECK constraint validation + ## [0.20.35] - 2026-04-20 ### Changed diff --git a/package.json b/package.json index bf5e7d0..ba1e63e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oikos", - "version": "0.20.35", + "version": "0.20.36", "description": "Self-hosted family planner - calendar, tasks, shopping, meal planning, budget and more. Private, open-source, no subscription.", "main": "server/index.js", "type": "module", @@ -23,7 +23,8 @@ "test:reminders": "node --experimental-sqlite test-reminders.js", "test:api": "node test-api.js", "test:ics-parser": "node test-ics-parser.js", - "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser" + "test:ics-sub": "node --experimental-sqlite test-ics-subscription.js", + "test": "node --experimental-sqlite test-db.js && node --experimental-sqlite test-dashboard.js && node --experimental-sqlite test-tasks.js && node --experimental-sqlite test-shopping.js && node --experimental-sqlite test-meals.js && node --experimental-sqlite test-calendar.js && node --experimental-sqlite test-notes-contacts-budget.js && npm run test:ux-utils && npm run test:modal-utils && npm run test:reminders && npm run test:api && npm run test:ics-parser && npm run test:ics-sub" }, "dependencies": { "bcrypt": "^6.0.0", diff --git a/server/db-schema-test.js b/server/db-schema-test.js index 5dc436b..331bd95 100644 --- a/server/db-schema-test.js +++ b/server/db-schema-test.js @@ -192,6 +192,45 @@ const MIGRATIONS_SQL = { CREATE INDEX IF NOT EXISTS idx_reminders_remind ON reminders(remind_at); CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(created_by); `, + 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; + `, }; export { MIGRATIONS_SQL }; diff --git a/server/db.js b/server/db.js index 1079106..1a35893 100644 --- a/server/db.js +++ b/server/db.js @@ -405,6 +405,76 @@ const MIGRATIONS = [ END; `, }, + { + 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; + `, + }, ]; /** diff --git a/test-ics-subscription.js b/test-ics-subscription.js new file mode 100644 index 0000000..531e398 --- /dev/null +++ b/test-ics-subscription.js @@ -0,0 +1,96 @@ +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;'); + +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); + const ids = rows.map(r => r.id); + const neujahr = db.prepare(`SELECT id FROM calendar_events WHERE external_calendar_id = 'neujahr@test' AND subscription_id = ?`).get(subId); + assert(!ids.includes(neujahr.id), '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);