769 lines
32 KiB
JavaScript
769 lines
32 KiB
JavaScript
/**
|
|
* Modul: Datenbank (Database)
|
|
* Zweck: SQLite/SQLCipher Verbindung, Schema-Migration (versioniert) und Query-Helfer
|
|
* Abhängigkeiten: better-sqlite3
|
|
*
|
|
* SQLCipher-Hinweis:
|
|
* Verschlüsselung funktioniert nur wenn better-sqlite3 gegen SQLCipher kompiliert wurde.
|
|
* Im Docker-Container (Dockerfile: libsqlcipher-dev + npm rebuild) ist das gewährleistet.
|
|
* Ohne DB_ENCRYPTION_KEY gesetzt läuft die App mit unverschlüsseltem SQLite (für Entwicklung).
|
|
*/
|
|
|
|
import Database from 'better-sqlite3';
|
|
import path from 'path';
|
|
import { createLogger } from './logger.js';
|
|
|
|
const log = createLogger('DB');
|
|
|
|
const DB_PATH = process.env.DB_PATH || path.join(import.meta.dirname, '..', 'oikos.db');
|
|
const DB_KEY = process.env.DB_ENCRYPTION_KEY;
|
|
|
|
let db;
|
|
|
|
// --------------------------------------------------------
|
|
// Initialisierung
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* Datenbankverbindung öffnen, SQLCipher-Key setzen, Migrations ausführen.
|
|
* Einmalig beim Serverstart aufrufen.
|
|
* @returns {import('better-sqlite3').Database}
|
|
*/
|
|
function init() {
|
|
if (db) return db;
|
|
db = new Database(DB_PATH);
|
|
|
|
if (DB_KEY) {
|
|
// Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker)
|
|
db.pragma(`key="x'${Buffer.from(DB_KEY, 'utf8').toString('hex')}'"`);
|
|
|
|
// Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist
|
|
try {
|
|
db.prepare('SELECT count(*) FROM sqlite_master').get();
|
|
} catch {
|
|
throw new Error('[DB] Wrong encryption key or SQLCipher support is unavailable.');
|
|
}
|
|
}
|
|
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
db.pragma('synchronous = NORMAL');
|
|
db.pragma('temp_store = MEMORY');
|
|
|
|
migrate();
|
|
|
|
log.info(`Connected: ${DB_PATH} | Schema v${currentVersion()}`);
|
|
return db;
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Migrations-Engine
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* Alle Migrationen in aufsteigender Reihenfolge.
|
|
* Neue Migrations am Ende anhängen - niemals bestehende ändern.
|
|
*/
|
|
const MIGRATIONS = [
|
|
{
|
|
version: 1,
|
|
description: 'Initial schema',
|
|
up: `
|
|
-- Benutzer
|
|
CREATE TABLE IF NOT EXISTS 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'
|
|
CHECK(role IN ('admin', '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'))
|
|
);
|
|
|
|
-- Aufgaben
|
|
CREATE TABLE IF NOT EXISTS tasks (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
priority TEXT NOT NULL DEFAULT 'none'
|
|
CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')),
|
|
status TEXT NOT NULL DEFAULT 'open'
|
|
CHECK(status IN ('open', 'in_progress', 'done')),
|
|
due_date TEXT,
|
|
due_time TEXT,
|
|
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
is_recurring INTEGER NOT NULL DEFAULT 0,
|
|
recurrence_rule TEXT,
|
|
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
|
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'))
|
|
);
|
|
|
|
-- Einkaufslisten
|
|
CREATE TABLE IF NOT EXISTS shopping_lists (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
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'))
|
|
);
|
|
|
|
-- Essensplan (muss vor shopping_items stehen wegen FK-Referenz)
|
|
CREATE TABLE IF NOT EXISTS meals (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
date TEXT NOT NULL,
|
|
meal_type TEXT NOT NULL
|
|
CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
|
|
title TEXT NOT NULL,
|
|
notes TEXT,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
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'))
|
|
);
|
|
|
|
-- Einkaufsartikel (nach meals, wegen added_from_meal FK)
|
|
CREATE TABLE IF NOT EXISTS shopping_items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
quantity TEXT,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
is_checked INTEGER NOT NULL DEFAULT 0,
|
|
added_from_meal INTEGER REFERENCES meals(id) ON DELETE SET NULL,
|
|
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'))
|
|
);
|
|
|
|
-- Mahlzeit-Zutaten
|
|
CREATE TABLE IF NOT EXISTS meal_ingredients (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
meal_id INTEGER NOT NULL REFERENCES meals(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
quantity TEXT,
|
|
on_shopping_list 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'))
|
|
);
|
|
|
|
-- Kalender-Events
|
|
CREATE TABLE IF NOT EXISTS 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')),
|
|
recurrence_rule 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'))
|
|
);
|
|
|
|
-- Pinnwand / Notizen
|
|
CREATE TABLE IF NOT EXISTS notes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT,
|
|
content TEXT NOT NULL,
|
|
color TEXT NOT NULL DEFAULT '#FFEB3B',
|
|
pinned INTEGER NOT NULL DEFAULT 0,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
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'))
|
|
);
|
|
|
|
-- Kontakte
|
|
CREATE TABLE IF NOT EXISTS contacts (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
phone TEXT,
|
|
email TEXT,
|
|
address TEXT,
|
|
notes 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'))
|
|
);
|
|
|
|
-- Budget
|
|
CREATE TABLE IF NOT EXISTS budget_entries (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
amount REAL NOT NULL,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
date TEXT NOT NULL,
|
|
is_recurring INTEGER NOT NULL DEFAULT 0,
|
|
recurrence_rule TEXT,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
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'))
|
|
);
|
|
|
|
-- --------------------------------------------------------
|
|
-- updated_at Trigger (automatisch bei UPDATE setzen)
|
|
-- --------------------------------------------------------
|
|
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
|
|
AFTER UPDATE ON users FOR EACH ROW
|
|
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_tasks_updated_at
|
|
AFTER UPDATE ON tasks FOR EACH ROW
|
|
BEGIN UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_shopping_lists_updated_at
|
|
AFTER UPDATE ON shopping_lists FOR EACH ROW
|
|
BEGIN UPDATE shopping_lists SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_shopping_items_updated_at
|
|
AFTER UPDATE ON shopping_items FOR EACH ROW
|
|
BEGIN UPDATE shopping_items SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_meals_updated_at
|
|
AFTER UPDATE ON meals FOR EACH ROW
|
|
BEGIN UPDATE meals SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_meal_ingredients_updated_at
|
|
AFTER UPDATE ON meal_ingredients FOR EACH ROW
|
|
BEGIN UPDATE meal_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS 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 TRIGGER IF NOT EXISTS trg_notes_updated_at
|
|
AFTER UPDATE ON notes FOR EACH ROW
|
|
BEGIN UPDATE notes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at
|
|
AFTER UPDATE ON contacts FOR EACH ROW
|
|
BEGIN UPDATE contacts SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at
|
|
AFTER UPDATE ON budget_entries FOR EACH ROW
|
|
BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
-- --------------------------------------------------------
|
|
-- Indizes
|
|
-- --------------------------------------------------------
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_shopping_items_list ON shopping_items(list_id);
|
|
CREATE INDEX IF NOT EXISTS idx_meals_date ON meals(date);
|
|
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_notes_pinned ON notes(pinned);
|
|
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
|
|
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
|
|
`,
|
|
},
|
|
{
|
|
version: 2,
|
|
description: 'Sync configuration table for Google/Apple Calendar',
|
|
up: `
|
|
CREATE TABLE IF NOT EXISTS sync_config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_calendar_external_id ON calendar_events(external_calendar_id);
|
|
`,
|
|
},
|
|
{
|
|
version: 3,
|
|
description: 'Recurring budget entries: parent reference and skip table',
|
|
up: `
|
|
ALTER TABLE budget_entries ADD COLUMN recurrence_parent_id INTEGER
|
|
REFERENCES budget_entries(id) ON DELETE SET NULL;
|
|
|
|
CREATE TABLE IF NOT EXISTS budget_recurrence_skipped (
|
|
parent_id INTEGER NOT NULL REFERENCES budget_entries(id) ON DELETE CASCADE,
|
|
month TEXT NOT NULL,
|
|
PRIMARY KEY (parent_id, month)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_budget_parent ON budget_entries(recurrence_parent_id);
|
|
`,
|
|
},
|
|
{
|
|
version: 4,
|
|
description: 'Allow "none" priority and set it as default',
|
|
up: `
|
|
-- SQLite erlaubt kein ALTER CHECK, daher Tabelle neu erstellen
|
|
CREATE TABLE tasks_new (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
description TEXT,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
priority TEXT NOT NULL DEFAULT 'none'
|
|
CHECK(priority IN ('none', 'low', 'medium', 'high', 'urgent')),
|
|
status TEXT NOT NULL DEFAULT 'open'
|
|
CHECK(status IN ('open', 'in_progress', 'done')),
|
|
due_date TEXT,
|
|
due_time TEXT,
|
|
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
is_recurring INTEGER NOT NULL DEFAULT 0,
|
|
recurrence_rule TEXT,
|
|
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
|
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 tasks_new SELECT * FROM tasks;
|
|
DROP TABLE tasks;
|
|
ALTER TABLE tasks_new RENAME TO tasks;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
|
CREATE INDEX IF NOT EXISTS idx_tasks_due ON tasks(due_date);
|
|
`,
|
|
},
|
|
{
|
|
version: 5,
|
|
description: 'Shopping categories as a separate table (customizable, sortable)',
|
|
up: `
|
|
CREATE TABLE IF NOT EXISTS shopping_categories (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL UNIQUE,
|
|
icon TEXT NOT NULL DEFAULT 'tag',
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
INSERT INTO shopping_categories (name, icon, sort_order) VALUES
|
|
('Obst & Gemüse', 'apple', 0),
|
|
('Backwaren', 'wheat', 1),
|
|
('Milchprodukte', 'milk', 2),
|
|
('Fleisch & Fisch', 'beef', 3),
|
|
('Tiefkühl', 'snowflake', 4),
|
|
('Getränke', 'cup-soda', 5),
|
|
('Haushalt', 'spray-can', 6),
|
|
('Drogerie', 'pill', 7),
|
|
('Sonstiges', 'shopping-basket', 8);
|
|
`,
|
|
},
|
|
{
|
|
version: 6,
|
|
description: 'Recipe URL for meals',
|
|
up: `
|
|
ALTER TABLE meals ADD COLUMN recipe_url TEXT;
|
|
`,
|
|
},
|
|
{
|
|
version: 7,
|
|
description: 'Category per ingredient for shopping list transfer',
|
|
up: `
|
|
ALTER TABLE meal_ingredients ADD COLUMN category TEXT NOT NULL DEFAULT 'Sonstiges';
|
|
`,
|
|
},
|
|
{
|
|
version: 8,
|
|
description: 'Reminders for tasks and calendar events',
|
|
up: `
|
|
CREATE TABLE IF NOT EXISTS reminders (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
entity_type TEXT NOT NULL CHECK(entity_type IN ('task', 'event')),
|
|
entity_id INTEGER NOT NULL,
|
|
remind_at TEXT NOT NULL,
|
|
dismissed INTEGER NOT NULL DEFAULT 0,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_reminders_entity ON reminders(entity_type, entity_id);
|
|
CREATE INDEX IF NOT EXISTS idx_reminders_remind ON reminders(remind_at);
|
|
CREATE INDEX IF NOT EXISTS idx_reminders_user ON reminders(created_by);
|
|
`,
|
|
},
|
|
{
|
|
version: 9,
|
|
description: 'Migrate task categories to English keys',
|
|
up: `
|
|
UPDATE tasks SET category = CASE category
|
|
WHEN 'Haushalt' THEN 'household'
|
|
WHEN 'Schule' THEN 'school'
|
|
WHEN 'Einkauf' THEN 'shopping'
|
|
WHEN 'Reparatur' THEN 'repair'
|
|
WHEN 'Gesundheit' THEN 'health'
|
|
WHEN 'Finanzen' THEN 'finance'
|
|
WHEN 'Freizeit' THEN 'leisure'
|
|
WHEN 'Sonstiges' THEN 'misc'
|
|
ELSE category
|
|
END;
|
|
`,
|
|
},
|
|
{
|
|
version: 10,
|
|
description: 'ICS subscriptions table',
|
|
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 IF NOT EXISTS idx_calendar_sub_extid
|
|
ON calendar_events (subscription_id, external_calendar_id)
|
|
WHERE subscription_id IS NOT NULL;
|
|
`,
|
|
},
|
|
{
|
|
version: 12,
|
|
description: 'calendar_events: replace partial unique index with full index (ON CONFLICT support)',
|
|
up: `
|
|
DROP INDEX IF EXISTS idx_calendar_sub_extid;
|
|
CREATE UNIQUE INDEX idx_calendar_sub_extid
|
|
ON calendar_events (subscription_id, external_calendar_id);
|
|
`,
|
|
},
|
|
{
|
|
version: 13,
|
|
description: 'Recipes table and meal association',
|
|
up: `
|
|
CREATE TABLE IF NOT EXISTS recipes (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
title TEXT NOT NULL,
|
|
notes TEXT,
|
|
recipe_url TEXT,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
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 TABLE IF NOT EXISTS recipe_ingredients (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
quantity TEXT,
|
|
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
|
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 INDEX IF NOT EXISTS idx_recipes_title ON recipes(title);
|
|
CREATE INDEX IF NOT EXISTS idx_recipe_ingredients_recipe ON recipe_ingredients(recipe_id);
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_recipes_updated_at
|
|
AFTER UPDATE ON recipes FOR EACH ROW
|
|
BEGIN UPDATE recipes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS trg_recipe_ingredients_updated_at
|
|
AFTER UPDATE ON recipe_ingredients FOR EACH ROW
|
|
BEGIN UPDATE recipe_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
|
|
|
ALTER TABLE meals ADD COLUMN recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_meals_recipe_id ON meals(recipe_id);
|
|
`,
|
|
},
|
|
{
|
|
version: 14,
|
|
description: 'External calendar metadata (name, color) and event association',
|
|
up: `
|
|
CREATE TABLE IF NOT EXISTS external_calendars (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
source TEXT NOT NULL CHECK(source IN ('google', 'apple')),
|
|
external_id TEXT NOT NULL,
|
|
name TEXT NOT NULL,
|
|
color TEXT,
|
|
UNIQUE(source, external_id)
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_ext_cal_source ON external_calendars(source, external_id);
|
|
|
|
ALTER TABLE calendar_events ADD COLUMN calendar_ref_id INTEGER
|
|
REFERENCES external_calendars(id) ON DELETE SET NULL;
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_cal_events_ref ON calendar_events(calendar_ref_id);
|
|
`,
|
|
},
|
|
{
|
|
version: 15,
|
|
description: 'Budget expense categories as stable keys with subcategories',
|
|
up: `
|
|
ALTER TABLE budget_entries ADD COLUMN subcategory TEXT NOT NULL DEFAULT '';
|
|
|
|
UPDATE budget_entries
|
|
SET category = CASE category
|
|
WHEN 'Lebensmittel' THEN 'food'
|
|
WHEN 'Miete' THEN 'housing'
|
|
WHEN 'Versicherung' THEN 'financial_other'
|
|
WHEN 'Mobilität' THEN 'transport'
|
|
WHEN 'Freizeit' THEN 'leisure'
|
|
WHEN 'Kleidung' THEN 'shopping_clothing'
|
|
WHEN 'Gesundheit' THEN 'personal_health'
|
|
WHEN 'Bildung' THEN 'education'
|
|
WHEN 'Sonstiges' THEN 'financial_other'
|
|
ELSE category
|
|
END
|
|
WHERE amount < 0;
|
|
|
|
UPDATE budget_entries
|
|
SET subcategory = CASE category
|
|
WHEN 'housing' THEN 'rent_mortgage'
|
|
WHEN 'food' THEN 'groceries'
|
|
WHEN 'transport' THEN 'fuel'
|
|
WHEN 'personal_health' THEN 'pharmacy'
|
|
WHEN 'leisure' THEN 'events'
|
|
WHEN 'shopping_clothing' THEN 'clothes_shoes'
|
|
WHEN 'education' THEN 'courses_college'
|
|
WHEN 'financial_other' THEN 'insurance_other'
|
|
ELSE ''
|
|
END
|
|
WHERE amount < 0 AND subcategory = '';
|
|
|
|
UPDATE budget_entries
|
|
SET category = 'Sonstiges Einkommen'
|
|
WHERE amount > 0 AND category = 'Sonstiges';
|
|
`,
|
|
},
|
|
{
|
|
version: 16,
|
|
description: 'Move budget categories and subcategories to separate tables',
|
|
up: `
|
|
CREATE TABLE IF NOT EXISTS budget_categories (
|
|
key TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
type TEXT NOT NULL CHECK(type IN ('expense', 'income')),
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS budget_subcategories (
|
|
key TEXT PRIMARY KEY,
|
|
category_key TEXT NOT NULL REFERENCES budget_categories(key) ON DELETE CASCADE,
|
|
name TEXT NOT NULL,
|
|
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
|
UNIQUE(category_key, name)
|
|
);
|
|
|
|
INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order) VALUES
|
|
('housing', 'Housing / Home', 'expense', 0),
|
|
('food', 'Food', 'expense', 1),
|
|
('transport', 'Transport', 'expense', 2),
|
|
('personal_health', 'Personal Care / Health', 'expense', 3),
|
|
('leisure', 'Leisure and Entertainment', 'expense', 4),
|
|
('shopping_clothing', 'Shopping and Clothing', 'expense', 5),
|
|
('education', 'Education', 'expense', 6),
|
|
('financial_other', 'Financial Services and Other', 'expense', 7),
|
|
('Erwerbseinkommen', 'Erwerbseinkommen', 'income', 0),
|
|
('Kapitalerträge', 'Kapitalerträge', 'income', 1),
|
|
('Geschenke & Transfers', 'Geschenke & Transfers', 'income', 2),
|
|
('Sozialleistungen', 'Sozialleistungen', 'income', 3),
|
|
('Sonstiges Einkommen', 'Sonstiges Einkommen', 'income', 4);
|
|
|
|
INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order) VALUES
|
|
('rent_mortgage', 'housing', 'Rent / Mortgage', 0),
|
|
('condominium', 'housing', 'Condominium fees', 1),
|
|
('utilities', 'housing', 'Electricity / Water / Gas', 2),
|
|
('internet_tv_phone', 'housing', 'Internet / TV / Phone', 3),
|
|
('renovation_maintenance', 'housing', 'Renovation / Maintenance', 4),
|
|
('cleaning', 'housing', 'Cleaning', 5),
|
|
('groceries', 'food', 'Groceries', 0),
|
|
('restaurants_bars', 'food', 'Restaurants / Bars', 1),
|
|
('snacks_fast_food', 'food', 'Snacks / Fast Food', 2),
|
|
('bakery', 'food', 'Bakery', 3),
|
|
('fuel', 'transport', 'Fuel', 0),
|
|
('parking_tolls', 'transport', 'Parking / Tolls', 1),
|
|
('public_transport', 'transport', 'Public transport', 2),
|
|
('apps_taxi', 'transport', 'Apps / Taxi', 3),
|
|
('maintenance_insurance', 'transport', 'Maintenance / Insurance', 4),
|
|
('pharmacy', 'personal_health', 'Pharmacy', 0),
|
|
('health_insurance', 'personal_health', 'Health insurance', 1),
|
|
('gym_sports', 'personal_health', 'Gym / Sports', 2),
|
|
('beauty_cosmetics', 'personal_health', 'Beauty / Cosmetics', 3),
|
|
('travel', 'leisure', 'Travel', 0),
|
|
('streaming', 'leisure', 'Streaming', 1),
|
|
('events', 'leisure', 'Events', 2),
|
|
('hobbies', 'leisure', 'Hobbies', 3),
|
|
('clothes_shoes', 'shopping_clothing', 'Clothes / Shoes', 0),
|
|
('electronics', 'shopping_clothing', 'Electronics', 1),
|
|
('gifts', 'shopping_clothing', 'Gifts', 2),
|
|
('courses_college', 'education', 'Courses / College', 0),
|
|
('school_supplies', 'education', 'School supplies', 1),
|
|
('languages', 'education', 'Languages', 2),
|
|
('loans_interest', 'financial_other', 'Loans / Interest', 0),
|
|
('bank_fees', 'financial_other', 'Bank fees', 1),
|
|
('insurance_other', 'financial_other', 'Insurance', 2),
|
|
('investments', 'financial_other', 'Investments', 3),
|
|
('taxes', 'financial_other', 'Taxes', 4);
|
|
|
|
INSERT OR IGNORE INTO budget_categories (key, name, type, sort_order)
|
|
SELECT category, category, CASE WHEN amount < 0 THEN 'expense' ELSE 'income' END, 1000
|
|
FROM budget_entries
|
|
WHERE category NOT IN (SELECT key FROM budget_categories)
|
|
GROUP BY category;
|
|
|
|
INSERT OR IGNORE INTO budget_subcategories (key, category_key, name, sort_order)
|
|
SELECT subcategory, category, subcategory, 1000
|
|
FROM budget_entries
|
|
WHERE subcategory != ''
|
|
AND subcategory NOT IN (SELECT key FROM budget_subcategories)
|
|
AND category IN (SELECT key FROM budget_categories WHERE type = 'expense')
|
|
GROUP BY category, subcategory;
|
|
`,
|
|
},
|
|
{
|
|
version: 17,
|
|
description: 'API tokens for non-interactive authentication',
|
|
up: `
|
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
name TEXT NOT NULL,
|
|
token_hash TEXT NOT NULL UNIQUE,
|
|
token_prefix TEXT NOT NULL,
|
|
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
expires_at TEXT,
|
|
revoked_at TEXT,
|
|
last_used_at TEXT,
|
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_created_by ON api_tokens(created_by);
|
|
`,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* Führt alle ausstehenden Migrations in einer Transaktion aus.
|
|
*/
|
|
function migrate() {
|
|
// Migrations-Versions-Tabelle sicherstellen (außerhalb der Haupt-Transaktion)
|
|
db.exec(`
|
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version INTEGER PRIMARY KEY,
|
|
description TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
|
);
|
|
`);
|
|
|
|
const applied = new Set(
|
|
db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version)
|
|
);
|
|
|
|
const pending = MIGRATIONS.filter((m) => !applied.has(m.version));
|
|
|
|
if (pending.length === 0) return;
|
|
|
|
const runMigration = db.transaction((migration) => {
|
|
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}`);
|
|
});
|
|
|
|
for (const migration of pending) {
|
|
runMigration(migration);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Aktuelle Schema-Version zurückgeben.
|
|
* @returns {number}
|
|
*/
|
|
function currentVersion() {
|
|
if (!db) return 0;
|
|
try {
|
|
const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
|
|
return row?.v ?? 0;
|
|
} catch {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
// --------------------------------------------------------
|
|
// Öffentliche API
|
|
// --------------------------------------------------------
|
|
|
|
/**
|
|
* Datenbankinstanz zurückgeben.
|
|
* @returns {import('better-sqlite3').Database}
|
|
*/
|
|
function get() {
|
|
if (!db) throw new Error('[DB] Not initialized - call init() first.');
|
|
return db;
|
|
}
|
|
|
|
/**
|
|
* Transaktion-Helfer: Funktion wird atomar ausgeführt.
|
|
* Bei Fehler wird automatisch rollback ausgeführt.
|
|
* @param {Function} fn
|
|
* @returns {any}
|
|
*/
|
|
function transaction(fn) {
|
|
return get().transaction(fn)();
|
|
}
|
|
|
|
init(); // auto-initialise when module is first imported
|
|
|
|
export { init, get, transaction, currentVersion };
|