feat(calendar): add ics_subscriptions table and calendar_events columns (migrations v10-v11)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user