feat: add DB migration for CalDAV multi-account support

- caldav_accounts table for account credentials
- caldav_calendar_selection table for calendar selection
- Migrate Apple CalDAV data to caldav tables
- Add target_caldav_* columns to calendar_events
- Update external_source CHECK to include 'caldav'
- Update external_calendars.source CHECK to include 'caldav'
- Enhance migration runner to support function-based migrations

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ulas Kalayci
2026-05-04 07:42:57 +02:00
parent 26168ebef0
commit 4eb7e852fd
+165
View File
@@ -914,6 +914,167 @@ const MIGRATIONS = [
CREATE INDEX IF NOT EXISTS idx_budget_loan_payments_paid_date ON budget_loan_payments(paid_date);
`,
},
{
version: 29,
description: 'Generic CalDAV multi-account support',
up: (db) => {
// Create caldav_accounts table
db.exec(`
CREATE TABLE caldav_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
caldav_url TEXT NOT NULL,
username TEXT NOT NULL,
password TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
last_sync TEXT,
UNIQUE(caldav_url, username)
)
`);
// Create caldav_calendar_selection table
db.exec(`
CREATE TABLE caldav_calendar_selection (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL,
calendar_url TEXT NOT NULL,
calendar_name TEXT NOT NULL,
calendar_color TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
FOREIGN KEY (account_id) REFERENCES caldav_accounts(id) ON DELETE CASCADE,
UNIQUE(account_id, calendar_url)
)
`);
// Create index for performance
db.exec(`
CREATE INDEX idx_caldav_selection_enabled
ON caldav_calendar_selection(account_id, enabled)
`);
// Update external_calendars to allow 'caldav' source
db.exec(`
CREATE TABLE external_calendars_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL CHECK(source IN ('google', 'apple', 'caldav')),
external_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT,
UNIQUE(source, external_id)
)
`);
db.exec(`
INSERT INTO external_calendars_new (id, source, external_id, name, color)
SELECT id, source, external_id, name, color
FROM external_calendars
`);
db.exec(`DROP TABLE external_calendars`);
db.exec(`ALTER TABLE external_calendars_new RENAME TO external_calendars`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_ext_cal_source ON external_calendars(source, external_id)`);
// Migrate existing Apple data
const appleUrl = db.prepare("SELECT value FROM sync_config WHERE key='apple_caldav_url'").get()?.value;
const appleUser = db.prepare("SELECT value FROM sync_config WHERE key='apple_username'").get()?.value;
const applePwd = db.prepare("SELECT value FROM sync_config WHERE key='apple_app_password'").get()?.value;
const appleLastSync = db.prepare("SELECT value FROM sync_config WHERE key='apple_last_sync'").get()?.value;
if (appleUrl && appleUser && applePwd) {
// Insert migrated Apple account
const result = db.prepare(`
INSERT INTO caldav_accounts (name, caldav_url, username, password, last_sync)
VALUES (?, ?, ?, ?, ?)
`).run('Apple Calendar (migriert)', appleUrl, appleUser, applePwd, appleLastSync);
const accountId = result.lastInsertRowid;
// Migrate Apple calendars from external_calendars
const appleCalendars = db.prepare(`
SELECT external_id, name, color FROM external_calendars WHERE source='apple'
`).all();
for (const cal of appleCalendars) {
db.prepare(`
INSERT INTO caldav_calendar_selection
(account_id, calendar_url, calendar_name, calendar_color, enabled)
VALUES (?, ?, ?, ?, 1)
`).run(accountId, cal.external_id, cal.name, cal.color);
}
// Update external_calendars source
db.prepare(`UPDATE external_calendars SET source='caldav' WHERE source='apple'`).run();
// Update calendar_events external_source
db.prepare(`UPDATE calendar_events SET external_source='caldav' WHERE external_source='apple'`).run();
}
// Add caldav to external_source CHECK constraint by recreating table
db.exec(`
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', 'caldav')),
recurrence_rule TEXT,
subscription_id INTEGER REFERENCES ics_subscriptions(id) ON DELETE CASCADE,
user_modified INTEGER NOT NULL DEFAULT 0,
calendar_ref_id INTEGER REFERENCES external_calendars(id) ON DELETE SET NULL,
icon TEXT NOT NULL DEFAULT 'calendar',
attachment_name TEXT,
attachment_mime TEXT,
attachment_size INTEGER,
attachment_data TEXT,
target_caldav_account_id INTEGER,
target_caldav_calendar_url TEXT,
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(`
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, calendar_ref_id, icon,
attachment_name, attachment_mime, attachment_size, attachment_data,
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,
subscription_id, user_modified, calendar_ref_id, icon,
attachment_name, attachment_mime, attachment_size, attachment_data,
created_at, updated_at
FROM calendar_events
`);
db.exec(`DROP TRIGGER IF EXISTS trg_calendar_events_updated_at`);
db.exec(`DROP TABLE calendar_events`);
db.exec(`ALTER TABLE calendar_events_new RENAME TO calendar_events`);
db.exec(`
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
`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_start ON calendar_events(start_datetime)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_assigned ON calendar_events(assigned_to)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_calendar_sub ON calendar_events(subscription_id)`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id)`);
db.exec(`CREATE UNIQUE INDEX idx_calendar_sub_extid ON calendar_events (subscription_id, external_calendar_id)`);
},
},
];
/**
@@ -938,7 +1099,11 @@ function migrate() {
if (pending.length === 0) return;
const runMigration = db.transaction((migration) => {
if (typeof migration.up === 'function') {
migration.up(db);
} else {
db.exec(migration.up);
}
db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)')
.run(migration.version, migration.description);
log.info(`Migration ${migration.version} applied: ${migration.description}`);