feat(calendar): add ics_subscriptions table and calendar_events columns (migrations v10-v11)

This commit is contained in:
Ulas Kalayci
2026-04-20 23:32:42 +02:00
parent 8479072afd
commit a64635b669
5 changed files with 216 additions and 2 deletions
+8
View File
@@ -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
+3 -2
View File
@@ -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",
+39
View File
@@ -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 };
+70
View File
@@ -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;
`,
},
];
/**
+96
View File
@@ -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);