feat: Phase 1 — Projektstruktur, DB-Schema, Auth-System

- Vollständige Verzeichnisstruktur gemäß CLAUDE.md
- Express-Server mit Helmet, Sessions, Rate Limiting, SPA-Fallback
- SQLite-Schema (Migration v1): 10 Tabellen, updated_at-Triggers, Indizes
- Versioniertes Migrations-System (schema_migrations)
- Auth-Routen: Login, Logout, /me, Admin-User-CRUD
- Frontend App-Shell: SPA-Router, API-Client, Design-System (CSS Tokens)
- PWA: Service Worker, Web App Manifest
- Setup-Script für ersten Admin-User (node setup.js)
- DB-Tests mit node:sqlite built-in: 29/29 bestanden
- Docker Compose + Dockerfile + Nginx-Beispielkonfiguration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ulsklyc
2026-03-24 14:32:36 +01:00
parent b3a6a6da2a
commit d49cbe33b3
44 changed files with 6635 additions and 0 deletions
+258
View File
@@ -0,0 +1,258 @@
/**
* Modul: Authentifizierung (Auth)
* Zweck: Login-Route, Session-Middleware, Auth-Guard für geschützte Routen
* Abhängigkeiten: express, bcrypt, express-session, connect-sqlite3, server/db.js
*/
'use strict';
require('dotenv').config();
const express = require('express');
const bcrypt = require('bcrypt');
const session = require('express-session');
const rateLimit = require('express-rate-limit');
const db = require('./db');
const router = express.Router();
// --------------------------------------------------------
// Session-Store (SQLite)
// --------------------------------------------------------
const SQLiteStore = require('connect-sqlite3')(session);
const sessionStore = new SQLiteStore({
db: 'sessions.db',
dir: process.env.DB_PATH ? require('path').dirname(process.env.DB_PATH) : '.',
ttl: 60 * 60 * 24 * 7, // 7 Tage in Sekunden
});
/**
* Session-Middleware konfigurieren.
* Wird in server/index.js eingebunden.
*/
const sessionMiddleware = session({
store: sessionStore,
secret: process.env.SESSION_SECRET || 'dev-secret-AENDERN-IN-PRODUKTION',
resave: false,
saveUninitialized: false,
name: 'oikos.sid',
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 Tage in ms
},
});
// --------------------------------------------------------
// Rate Limiting für Login
// --------------------------------------------------------
const loginLimiter = rateLimit({
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
max: parseInt(process.env.RATE_LIMIT_MAX_ATTEMPTS) || 5,
skipSuccessfulRequests: true,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Zu viele Login-Versuche. Bitte warte kurz.', code: 429 },
});
// --------------------------------------------------------
// Auth-Guard Middleware
// --------------------------------------------------------
/**
* Prüft ob der Request authentifiziert ist.
* Schützt alle API-Routen außer /auth/login.
*/
function requireAuth(req, res, next) {
if (req.session && req.session.userId) {
return next();
}
res.status(401).json({ error: 'Nicht authentifiziert.', code: 401 });
}
/**
* Prüft ob der authentifizierte User Admin-Rolle hat.
*/
function requireAdmin(req, res, next) {
if (req.session && req.session.role === 'admin') {
return next();
}
res.status(403).json({ error: 'Keine Berechtigung.', code: 403 });
}
// --------------------------------------------------------
// Routen
// --------------------------------------------------------
/**
* POST /api/v1/auth/login
* Body: { username: string, password: string }
* Response: { user: { id, username, display_name, avatar_color, role } }
*/
router.post('/login', loginLimiter, async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich.', code: 400 });
}
const user = db.get().prepare('SELECT * FROM users WHERE username = ?').get(username);
if (!user) {
// Timing-Attack-Schutz: trotzdem bcrypt ausführen
await bcrypt.compare(password, '$2b$12$invalidhashfortimingprotection000000000000000000000');
return res.status(401).json({ error: 'Ungültige Anmeldedaten.', code: 401 });
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
return res.status(401).json({ error: 'Ungültige Anmeldedaten.', code: 401 });
}
req.session.regenerate((err) => {
if (err) {
console.error('[Auth] Session-Regenerierung fehlgeschlagen:', err);
return res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
req.session.userId = user.id;
req.session.role = user.role;
res.json({
user: {
id: user.id,
username: user.username,
display_name: user.display_name,
avatar_color: user.avatar_color,
role: user.role,
},
});
});
} catch (err) {
console.error('[Auth] Login-Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
/**
* POST /api/v1/auth/logout
* Response: { ok: true }
*/
router.post('/logout', requireAuth, (req, res) => {
req.session.destroy((err) => {
if (err) {
console.error('[Auth] Logout-Fehler:', err);
return res.status(500).json({ error: 'Logout fehlgeschlagen.', code: 500 });
}
res.clearCookie('oikos.sid');
res.json({ ok: true });
});
});
/**
* GET /api/v1/auth/me
* Response: { user: { id, username, display_name, avatar_color, role } }
*/
router.get('/me', requireAuth, (req, res) => {
try {
const user = db.get()
.prepare('SELECT id, username, display_name, avatar_color, role FROM users WHERE id = ?')
.get(req.session.userId);
if (!user) {
req.session.destroy(() => {});
return res.status(401).json({ error: 'Benutzer nicht gefunden.', code: 401 });
}
res.json({ user });
} catch (err) {
console.error('[Auth] /me Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
/**
* GET /api/v1/auth/users
* Admin only. Listet alle Familienmitglieder.
* Response: { data: User[] }
*/
router.get('/users', requireAuth, requireAdmin, (req, res) => {
try {
const users = db.get()
.prepare('SELECT id, username, display_name, avatar_color, role, created_at FROM users ORDER BY display_name')
.all();
res.json({ data: users });
} catch (err) {
console.error('[Auth] Users-Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
/**
* POST /api/v1/auth/users
* Admin only. Erstellt neues Familienmitglied.
* Body: { username, display_name, password, avatar_color?, role? }
* Response: { user: { id, username, display_name, avatar_color, role } }
*/
router.post('/users', requireAuth, requireAdmin, async (req, res) => {
try {
const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body;
if (!username || !display_name || !password) {
return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 });
}
if (!['admin', 'member'].includes(role)) {
return res.status(400).json({ error: 'Ungültige Rolle.', code: 400 });
}
const hash = await bcrypt.hash(password, 12);
const result = db.get()
.prepare(`
INSERT INTO users (username, display_name, password_hash, avatar_color, role)
VALUES (?, ?, ?, ?, ?)
`)
.run(username, display_name, hash, avatar_color, role);
res.status(201).json({
user: { id: result.lastInsertRowid, username, display_name, avatar_color, role },
});
} catch (err) {
if (err.message && err.message.includes('UNIQUE constraint')) {
return res.status(409).json({ error: 'Benutzername bereits vergeben.', code: 409 });
}
console.error('[Auth] User-Erstellen-Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
/**
* DELETE /api/v1/auth/users/:id
* Admin only. Löscht ein Familienmitglied.
* Response: { ok: true }
*/
router.delete('/users/:id', requireAuth, requireAdmin, (req, res) => {
try {
const userId = parseInt(req.params.id, 10);
if (userId === req.session.userId) {
return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden.', code: 400 });
}
const result = db.get().prepare('DELETE FROM users WHERE id = ?').run(userId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 });
}
res.json({ ok: true });
} catch (err) {
console.error('[Auth] User-Löschen-Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
}
});
module.exports = { router, sessionMiddleware, requireAuth, requireAdmin };
+177
View File
@@ -0,0 +1,177 @@
/**
* Modul: DB-Schema-Export für Tests
* Zweck: SQL-Strings aus MIGRATIONS für node:sqlite-Tests exportieren.
* Nur für Testzwecke — db.js nutzt die MIGRATIONS direkt intern.
* Abhängigkeiten: keine
*/
'use strict';
// SQL-String für Migration v1 (gespiegelt aus db.js MIGRATIONS[0].up)
// Änderungen in db.js MIGRATIONS müssen hier synchron gehalten werden.
const MIGRATIONS_SQL = {
1: `
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'))
);
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 'medium'
CHECK(priority IN ('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'))
);
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'))
);
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'))
);
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'))
);
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'))
);
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'))
);
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'))
);
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'))
);
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'))
);
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;
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);
`,
};
module.exports = { MIGRATIONS_SQL };
+342
View File
@@ -0,0 +1,342 @@
/**
* Modul: Datenbank (Database)
* Zweck: SQLite/SQLCipher Verbindung, Schema-Migration (versioniert) und Query-Helfer
* Abhängigkeiten: better-sqlite3, dotenv
*
* 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).
*/
'use strict';
require('dotenv').config();
const Database = require('better-sqlite3');
const path = require('path');
const DB_PATH = process.env.DB_PATH || path.join(__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() {
db = new Database(DB_PATH);
if (DB_KEY) {
// Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker)
db.pragma(`key='${DB_KEY}'`);
// Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist
try {
db.prepare('SELECT count(*) FROM sqlite_master').get();
} catch {
throw new Error('[DB] Falscher Verschlüsselungsschlüssel oder keine SQLCipher-Unterstützung.');
}
}
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.pragma('synchronous = NORMAL');
db.pragma('temp_store = MEMORY');
migrate();
console.log(`[DB] Verbunden: ${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: 'Initiales 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 'medium'
CHECK(priority IN ('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);
`,
},
// Zukünftige Migrations hier anhängen:
// { version: 2, description: '...', up: '...' },
];
/**
* 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);
console.log(`[DB] Migration ${migration.version} angewendet: ${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] Nicht initialisiert — init() zuerst aufrufen.');
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)();
}
module.exports = { init, get, transaction, currentVersion };
+124
View File
@@ -0,0 +1,124 @@
/**
* Modul: Server Entry Point
* Zweck: Express-App initialisieren, Middleware einbinden, Routen registrieren
* Abhängigkeiten: express, helmet, dotenv, server/db.js, server/auth.js, server/routes/*
*/
'use strict';
require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const path = require('path');
const db = require('./db');
const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth');
const app = express();
const PORT = process.env.PORT || 3000;
// --------------------------------------------------------
// Datenbank initialisieren
// --------------------------------------------------------
db.init();
// --------------------------------------------------------
// Security-Middleware
// --------------------------------------------------------
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
// Alpine.js CDN
'https://cdn.jsdelivr.net',
// Lucide Icons CDN
'https://unpkg.com',
],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https://openweathermap.org'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
},
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
}));
// Trust Proxy für korrekte IP hinter Nginx
app.set('trust proxy', 1);
// --------------------------------------------------------
// Request-Parsing
// --------------------------------------------------------
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// --------------------------------------------------------
// Sessions
// --------------------------------------------------------
app.use(sessionMiddleware);
// --------------------------------------------------------
// Statische Dateien (Frontend)
// --------------------------------------------------------
app.use(express.static(path.join(__dirname, '..', 'public'), {
maxAge: process.env.NODE_ENV === 'production' ? '7d' : 0,
etag: true,
}));
// --------------------------------------------------------
// API-Routen
// --------------------------------------------------------
app.use('/api/v1/auth', authRouter);
// Alle weiteren API-Routen erfordern Authentifizierung
app.use('/api/v1', requireAuth);
app.use('/api/v1/tasks', require('./routes/tasks'));
app.use('/api/v1/shopping', require('./routes/shopping'));
app.use('/api/v1/meals', require('./routes/meals'));
app.use('/api/v1/calendar', require('./routes/calendar'));
app.use('/api/v1/notes', require('./routes/notes'));
app.use('/api/v1/contacts', require('./routes/contacts'));
app.use('/api/v1/budget', require('./routes/budget'));
app.use('/api/v1/weather', require('./routes/weather'));
// --------------------------------------------------------
// Health-Check (für Docker)
// --------------------------------------------------------
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// --------------------------------------------------------
// SPA Fallback: Alle nicht-API-Routen → index.html
// --------------------------------------------------------
app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Nicht gefunden.', code: 404 });
}
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
});
// --------------------------------------------------------
// Globaler Error-Handler
// --------------------------------------------------------
app.use((err, req, res, _next) => {
console.error('[Server] Unbehandelter Fehler:', err);
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
});
// --------------------------------------------------------
// Server starten
// --------------------------------------------------------
app.listen(PORT, () => {
console.log(`[Oikos] Server läuft auf Port ${PORT}`);
console.log(`[Oikos] Umgebung: ${process.env.NODE_ENV || 'development'}`);
});
module.exports = app;
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Budget-Tracker (Budget)
* Zweck: REST-API-Routen für Einnahmen und Ausgaben
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Kalender (Calendar)
* Zweck: REST-API-Routen für Kalendereinträge und externe Kalender-Sync
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Kontakte (Contacts)
* Zweck: REST-API-Routen für wichtige Familienkontakte
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Essensplan (Meals)
* Zweck: REST-API-Routen für Mahlzeiten und Zutaten
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
// Platzhalter — wird in Phase 2 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Pinnwand / Notizen (Notes)
* Zweck: REST-API-Routen für Notizen
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
// Platzhalter — wird in Phase 3 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Einkaufslisten (Shopping)
* Zweck: REST-API-Routen für Einkaufslisten und -artikel
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
// Platzhalter — wird in Phase 2 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Aufgaben (Tasks)
* Zweck: REST-API-Routen für Aufgaben und Teilaufgaben
* Abhängigkeiten: express, server/db.js, server/auth.js
*/
const express = require('express');
const router = express.Router();
// Platzhalter — wird in Phase 2 implementiert
router.get('/', (req, res) => res.json({ data: [] }));
module.exports = router;
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Wetter-Proxy (Weather)
* Zweck: Serverseitiger Proxy für OpenWeatherMap API (API-Key nie im Frontend)
* Abhängigkeiten: express, node-fetch, dotenv
*/
const express = require('express');
const router = express.Router();
// Platzhalter — wird in Phase 4 implementiert
router.get('/', (req, res) => res.json({ data: null }));
module.exports = router;
+11
View File
@@ -0,0 +1,11 @@
/**
* Modul: Apple Calendar Sync (CalDAV)
* Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll
* Abhängigkeiten: tsdav, server/db.js
*/
// Platzhalter — wird in Phase 3 implementiert
module.exports = {
sync: async () => null,
};
+13
View File
@@ -0,0 +1,13 @@
/**
* Modul: Google Calendar Sync
* Zweck: OAuth 2.0 + bidirektionaler Sync mit Google Calendar API v3
* Abhängigkeiten: googleapis, server/db.js
*/
// Platzhalter — wird in Phase 3 implementiert
module.exports = {
getAuthUrl: () => null,
handleCallback: async () => null,
sync: async () => null,
};
+12
View File
@@ -0,0 +1,12 @@
/**
* Modul: Wiederholungsregeln (Recurrence)
* Zweck: RRULE-Parser und -Generator für wiederkehrende Aufgaben und Termine
* Abhängigkeiten: keine externen (eigene Implementierung für FREQ=DAILY/WEEKLY/MONTHLY)
*/
// Platzhalter — wird in Phase 4 implementiert
module.exports = {
nextOccurrence: () => null,
expandRule: () => [],
};