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:
+258
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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: () => [],
|
||||
};
|
||||
Reference in New Issue
Block a user