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
+34
View File
@@ -0,0 +1,34 @@
# Oikos — Umgebungsvariablen
# Kopiere diese Datei nach .env und passe die Werte an.
# Server
PORT=3000
NODE_ENV=production
# Session
SESSION_SECRET=HIER_EINEN_LANGEN_ZUFAELLIGEN_STRING_EINTRAGEN
# Datenbank (SQLite/SQLCipher)
DB_PATH=/data/oikos.db
DB_ENCRYPTION_KEY=HIER_EINEN_STARKEN_VERSCHLUESSELUNGSSCHLUESSEL_EINTRAGEN
# Wetter (OpenWeatherMap)
OPENWEATHER_API_KEY=DEIN_API_KEY
OPENWEATHER_CITY=Berlin
OPENWEATHER_UNITS=metric
OPENWEATHER_LANG=de
# Google Calendar (optional)
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=https://deine-domain.de/api/v1/calendar/google/callback
# Apple Calendar CalDAV (optional)
APPLE_CALDAV_URL=https://caldav.icloud.com
APPLE_USERNAME=
APPLE_APP_SPECIFIC_PASSWORD=
# Sicherheit
RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX_ATTEMPTS=5
RATE_LIMIT_BLOCK_DURATION_MS=900000
+28
View File
@@ -0,0 +1,28 @@
# Abhängigkeiten
node_modules/
# Umgebungsvariablen (NIEMALS committen)
.env
# Datenbank
*.db
*.db-shm
*.db-wal
# Logs
*.log
logs/
# System
.DS_Store
Thumbs.db
# Build-Artefakte
dist/
.cache/
# IDE
.vscode/
.idea/
*.swp
*.swo
+516
View File
@@ -0,0 +1,516 @@
# CLAUDE.md — Familienplaner Web-App „Oikos"
<role>
Du bist ein Senior Full-Stack-Entwickler mit Expertise in Self-Hosted Web-Apps, Progressive Web Apps, Datenbankdesign und UX/UI-Design. Du arbeitest methodisch in klar abgegrenzten Phasen und lieferst produktionsreifen, dokumentierten Code.
</role>
<project_context>
Selbstgehostete Familienplaner-Web-App mit dem Namen "Oikos" für eine einzelne Familie (26 Personen). Kein App-Store, kein öffentlicher Zugang. Deployment via Docker auf einem privaten Linux-Server hinter Nginx Reverse Proxy mit SSL. Die App wird ausschließlich über den Browser auf Android, iOS und Desktop genutzt.
</project_context>
---
## ARCHITEKTUR-ENTSCHEIDUNGEN
<tech_stack>
### Backend
- **Runtime:** Node.js (LTS)
- **Framework:** Express.js (minimalistisch, nur Routing + Middleware)
- **Datenbank:** SQLite mit **better-sqlite3** + **SQLCipher** (Verschlüsselung at rest)
- **Auth:** Session-basiert mit **bcrypt** (Passwort-Hashing) + **express-session** mit SQLite-Session-Store
- **API-Design:** RESTful JSON-API, alle Routen unter `/api/v1/`
- **Kalender-Sync:** CalDAV-Client-Library für Google Calendar / Apple Calendar Integration
- **Wetter-API:** OpenWeatherMap Free Tier (serverseitiger Proxy, API-Key nie im Frontend)
### Frontend
- **Kein Build-Step.** Kein React, kein Vue, kein Webpack, kein Bundler.
- **Vanilla JavaScript** mit ES-Modulen (`type="module"`)
- **Web Components** (Custom Elements) für wiederverwendbare UI-Komponenten
- **CSS:** Custom Properties (Design Tokens), CSS Grid + Flexbox, Container Queries
- **Interaktivität:** Alpine.js (optional, ~15kb, CDN-Einbindung, kein Build nötig) — NUR wenn DOM-Manipulation in Vanilla JS zu verbose wird
- **Icons:** Lucide Icons (SVG, CDN)
- **PWA:** Service Worker für Offline-Grundfunktionalität, Web App Manifest für „Add to Homescreen"
### Deployment
- **Docker Compose:** Ein Container für die App (Node.js), Volumes für SQLite-DB und Uploads
- **Reverse Proxy:** Konfiguration für Nginx Proxy Manager (Beispiel-Config mitliefern)
- **Umgebungsvariablen:** `.env`-Datei für alle Secrets (DB-Passwort, API-Keys, Session-Secret)
</tech_stack>
<architecture_principles>
1. **Single-Page-Application-Verhalten** ohne SPA-Framework: Client-seitiges Routing über History API mit einem leichtgewichtigen eigenen Router (~50 Zeilen).
2. **API-First:** Jedes Modul hat eine saubere REST-API. Das Frontend ist ein reiner API-Consumer.
3. **Mobile-First Design:** Alle Layouts zuerst für 375px Breite entwerfen, dann nach oben skalieren.
4. **Offline-Tolerant:** Service Worker cached App-Shell. Daten werden bei Reconnect synchronisiert.
5. **Keine Telemetrie, kein Tracking, keine externen Fonts.** Alles self-contained.
</architecture_principles>
---
## DATENMODELL
<database_schema>
Entwirf das Schema nach diesen Entitäten. Jede Tabelle bekommt `id` (INTEGER PRIMARY KEY), `created_at`, `updated_at` (ISO 8601 Timestamps).
### Users
- `username` (UNIQUE, NOT NULL)
- `display_name`
- `password_hash` (bcrypt)
- `avatar_color` (HEX — für farbliche Zuordnung im UI)
- `role` (ENUM: 'admin', 'member') — Admin kann Familienmitglieder verwalten
### Tasks (Aufgaben-Modul)
- `title` (NOT NULL)
- `description` (Notizfeld, TEXT)
- `category` (z.B. Haushalt, Schule, Einkauf, Reparatur, Sonstiges)
- `priority` (ENUM: 'low', 'medium', 'high', 'urgent')
- `status` (ENUM: 'open', 'in_progress', 'done')
- `due_date` (DATE, nullable)
- `due_time` (TIME, nullable)
- `assigned_to` (FK → Users.id, nullable)
- `created_by` (FK → Users.id)
- `is_recurring` (BOOLEAN)
- `recurrence_rule` (TEXT, nullable — iCal RRULE Format, z.B. `FREQ=WEEKLY;BYDAY=MO,TH`)
- `parent_task_id` (FK → Tasks.id, nullable — für Teilaufgaben)
### Shopping Lists (Einkaufslisten-Modul)
- `name` (z.B. "Wocheneinkauf", "Baumarkt")
### Shopping Items
- `list_id` (FK → Shopping Lists.id)
- `name` (NOT NULL)
- `quantity` (TEXT, z.B. "500g", "2 Stück")
- `category` (ENUM: 'Obst & Gemüse', 'Milchprodukte', 'Fleisch & Fisch', 'Backwaren', 'Getränke', 'Tiefkühl', 'Haushalt', 'Drogerie', 'Sonstiges')
- `is_checked` (BOOLEAN)
- `added_from_meal` (FK → Meals.id, nullable — Herkunft aus Essensplan)
### Meals (Essensplan-Modul)
- `date` (DATE, NOT NULL)
- `meal_type` (ENUM: 'breakfast', 'lunch', 'dinner', 'snack')
- `title` (NOT NULL, z.B. "Spaghetti Bolognese")
- `notes` (TEXT, nullable)
- `created_by` (FK → Users.id)
### Meal Ingredients
- `meal_id` (FK → Meals.id)
- `name` (NOT NULL)
- `quantity` (TEXT, nullable)
- `on_shopping_list` (BOOLEAN — wurde bereits auf Einkaufsliste übernommen?)
### Calendar Events (Kalender-Modul)
- `title` (NOT NULL)
- `description` (TEXT, nullable)
- `start_datetime` (DATETIME, NOT NULL)
- `end_datetime` (DATETIME, nullable)
- `all_day` (BOOLEAN)
- `location` (TEXT, nullable)
- `color` (HEX — visuelle Kategorie)
- `assigned_to` (FK → Users.id, nullable)
- `created_by` (FK → Users.id)
- `external_calendar_id` (TEXT, nullable — ID aus Google/Apple Calendar)
- `external_source` (ENUM: 'local', 'google', 'apple')
- `recurrence_rule` (TEXT, nullable — iCal RRULE)
### Notes (Pinnwand-Modul)
- `title` (nullable)
- `content` (TEXT, NOT NULL)
- `color` (HEX — Sticky-Note-Farbe)
- `pinned` (BOOLEAN)
- `created_by` (FK → Users.id)
### Contacts (Wichtige Kontakte)
- `name` (NOT NULL)
- `category` (ENUM: 'Arzt', 'Schule/Kita', 'Behörde', 'Versicherung', 'Handwerker', 'Notfall', 'Sonstiges')
- `phone` (TEXT, nullable)
- `email` (TEXT, nullable)
- `address` (TEXT, nullable)
- `notes` (TEXT, nullable)
### Budget Entries (Budget-Tracker)
- `title` (NOT NULL)
- `amount` (REAL, NOT NULL — positiv = Einnahme, negativ = Ausgabe)
- `category` (ENUM: 'Lebensmittel', 'Miete', 'Versicherung', 'Mobilität', 'Freizeit', 'Kleidung', 'Gesundheit', 'Bildung', 'Sonstiges')
- `date` (DATE, NOT NULL)
- `is_recurring` (BOOLEAN)
- `recurrence_rule` (TEXT, nullable)
- `created_by` (FK → Users.id)
</database_schema>
---
## MODULE — FUNKTIONALE SPEZIFIKATIONEN
<module_dashboard>
### Dashboard (Startseite)
**Route:** `/`
**Layout:** Responsive Grid (1 Spalte mobil, 2 Spalten Tablet, 3 Spalten Desktop).
**Widgets:**
1. **Begrüßung** — "Guten [Morgen/Tag/Abend], [Name]" mit aktuellem Datum
2. **Wetter-Widget** — Aktuelles Wetter + 3-Tage-Vorschau. Standort konfigurierbar in Settings. Daten vom Backend-Proxy (OpenWeatherMap). Refresh alle 30 Minuten. Fallback bei API-Fehler: Widget ausblenden, kein Error-Screen.
3. **Anstehende Termine** — Nächste 35 Termine aus dem Kalender-Modul. Farbcodiert nach Person. Klick → Kalender-Modul.
4. **Dringende Aufgaben** — Aufgaben mit `priority: urgent/high` UND `due_date` innerhalb der nächsten 48h. Sortiert nach Fälligkeit. Klick → Aufgaben-Modul.
5. **Heutiges Essen** — Mahlzeiten für heute aus dem Essensplan. Zeigt Titel + Meal-Type. Klick → Essensplan-Modul.
6. **Pinnwand-Vorschau** — Letzte 23 angepinnte Notizen. Klick → Pinnwand.
7. **Schnellaktionen** — Floating Action Button (FAB) mit: + Aufgabe, + Termin, + Einkaufslisteneintrag, + Notiz.
**Design-Vorgaben:**
- Widgets als Cards mit `border-radius: 12px`, subtiler `box-shadow`
- Farbschema: Neutraler Hintergrund (`#F5F5F7`), Cards weiß, Akzentfarbe konfigurierbar
- Smooth Scroll, keine abrupten Übergänge
- Skeleton-Loading-States während API-Calls (keine Spinner)
</module_dashboard>
<module_tasks>
### Aufgaben-Modul
**Route:** `/tasks`
**Ansichten:**
1. **Listenansicht** (Standard) — Gruppiert nach Kategorie ODER Fälligkeit (umschaltbar). Filter: Person, Priorität, Status.
2. **Kanban-Ansicht** — Spalten: Offen → In Bearbeitung → Erledigt. Drag & Drop zum Statuswechsel.
**Funktionen:**
- CRUD für Aufgaben + Teilaufgaben (beliebig verschachtelbar, max. 2 Ebenen)
- Zuweisung an Familienmitglied (Avatar-Farbe als Indikator)
- Dringlichkeitsstufen visuell durch Farbe/Icon codiert
- Wiederkehrende Aufgaben: Bei Erledigung wird automatisch die nächste Instanz erstellt
- Swipe-Gesten auf Mobil: Links = erledigt, Rechts = bearbeiten
- Benachrichtigung (In-App-Badge) bei überfälligen Aufgaben
**Teilaufgaben:**
- Checkbox-Liste innerhalb einer Aufgabe
- Fortschrittsbalken (z.B. 3/5 Teilaufgaben erledigt)
- Eigene Notiz pro Teilaufgabe
</module_tasks>
<module_shopping>
### Einkaufslisten-Modul
**Route:** `/shopping`
**Funktionen:**
- Mehrere Listen parallel (z.B. "REWE", "dm", "Baumarkt")
- Artikel mit Kategorie, Menge, Checkbox
- Automatische Gruppierung nach Kategorie (Supermarkt-Gang-Logik)
- **Essensplan-Integration:** Button "Zutaten auf Einkaufsliste" im Essensplan → Zutaten werden mit Quell-Referenz übernommen
- Erledigte Artikel werden durchgestrichen, nach unten sortiert
- "Liste leeren" entfernt nur abgehakte Artikel
- Artikel-Vorschläge basierend auf bisherigen Einträgen (lokaler Autocomplete, kein externer Service)
</module_shopping>
<module_mealplan>
### Essensplan-Modul
**Route:** `/meals`
**Layout:** Wochenansicht (MoSo), jeder Tag mit Slots für Frühstück/Mittag/Abend/Snack.
**Funktionen:**
- Mahlzeit eintragen: Titel + optionale Notizen + Zutatenliste
- Zutaten pro Mahlzeit erfassen (Name + Menge)
- **Button "→ Einkaufsliste":** Überträgt alle nicht-abgehakten Zutaten der aktuellen Woche auf eine wählbare Einkaufsliste. Bereits übertragene Zutaten werden markiert.
- Wochennavigation (vor/zurück)
- Mahlzeiten per Drag & Drop zwischen Tagen/Slots verschieben
- Vergangene Mahlzeiten als Vorschläge beim Tippen (Autocomplete aus Historie)
</module_mealplan>
<module_calendar>
### Familienkalender-Modul
**Route:** `/calendar`
**Ansichten:**
1. **Monatsansicht** (Standard) — Tage mit Punkt-Indikatoren für Termine
2. **Wochenansicht** — Stundenraster mit Terminblöcken
3. **Tagesansicht** — Detaillierte Timeline
4. **Agenda-Ansicht** — Chronologische Liste aller kommenden Termine
**Funktionen:**
- CRUD für Termine (Titel, Beschreibung, Start/Ende, Ganztägig, Ort, Farbe, Zuweisung)
- Farbcodierung pro Person (Avatar-Farbe aus User-Profil)
- Wiederkehrende Termine (iCal RRULE)
- **Kalender-Sync:**
- **Google Calendar:** OAuth 2.0 → Google Calendar API v3. Zwei-Wege-Sync.
- **Apple Calendar (iCloud):** CalDAV-Protokoll. Zwei-Wege-Sync.
- Sync-Intervall konfigurierbar (Standard: alle 15 Minuten)
- Externe Termine visuell unterscheidbar (dezenter Badge/Icon)
- Konflikte: Externes Event gewinnt bei Änderungen, lokale Ergänzungen bleiben erhalten
</module_calendar>
<module_notes>
### Pinnwand / Notizen-Modul
**Route:** `/notes`
**Layout:** Masonry-Grid (Pinterest-Style) mit farbigen Sticky Notes.
**Funktionen:**
- CRUD für Notizen (Titel optional, Inhalt, Farbe wählbar)
- Anpinnen (erscheint oben + auf Dashboard)
- Ersteller wird angezeigt (Avatar-Farbe)
- Markdown-Light im Inhalt (fett, kursiv, Listen — kein Full-Markdown-Parser nötig, regex-basiert)
</module_notes>
<module_contacts>
### Wichtige Kontakte
**Route:** `/contacts`
**Funktionen:**
- CRUD für Kontakte mit Kategorie-Filter
- Telefonnummer als `tel:`-Link (direkter Anruf auf Mobil)
- E-Mail als `mailto:`-Link
- Adresse als Link zu Google Maps / Apple Maps (User-Agent-Detection)
- Suchfeld mit Echtzeit-Filter
</module_contacts>
<module_budget>
### Budget-Tracker
**Route:** `/budget`
**Ansichten:**
1. **Monatsübersicht** — Einnahmen vs. Ausgaben, Saldo. Balkendiagramm nach Kategorie (Canvas-basiert, keine Chart-Library).
2. **Transaktionsliste** — Chronologisch, filterbar nach Kategorie/Monat.
**Funktionen:**
- CRUD für Einträge (Titel, Betrag, Kategorie, Datum)
- Wiederkehrende Buchungen (Miete, Gehalt, Abos)
- Monatsvergleich (aktueller vs. Vormonat)
- Export als CSV
</module_budget>
---
## AUTHENTIFIZIERUNG & SICHERHEIT
<security>
1. **Login-Screen:** Username + Passwort. Kein öffentlicher Registrierungs-Endpoint. Neue User werden nur durch Admin erstellt.
2. **Passwort-Hashing:** bcrypt mit Cost Factor 12.
3. **Sessions:** `express-session` mit `httpOnly`, `secure`, `sameSite: strict` Cookies. Session-Timeout: 7 Tage.
4. **CSRF-Protection:** Double Submit Cookie Pattern.
5. **Rate Limiting:** 5 Login-Versuche pro Minute pro IP, danach 15min Sperre.
6. **Datenbank-Verschlüsselung:** SQLCipher (AES-256-CBC). Schlüssel aus `.env`.
7. **Input Validation:** Alle API-Inputs serverseitig validieren. Kein `eval()`, kein `innerHTML` mit User-Input.
8. **Content Security Policy:** Strikte CSP-Header. Nur eigene Ressourcen + explizit erlaubte CDNs.
9. **HTTPS-only:** App setzt `Strict-Transport-Security` Header. Redirect HTTP → HTTPS.
</security>
---
## UI/UX DESIGN-SYSTEM
<design_system>
### Farben (CSS Custom Properties)
```css
:root {
/* Neutrals */
--color-bg: #F5F5F7;
--color-surface: #FFFFFF;
--color-border: #E5E5EA;
--color-text-primary: #1C1C1E;
--color-text-secondary: #8E8E93;
/* Akzent (konfigurierbar pro Installation) */
--color-accent: #007AFF;
--color-accent-light: #E3F2FF;
/* Semantisch */
--color-success: #34C759;
--color-warning: #FF9500;
--color-danger: #FF3B30;
--color-info: #5AC8FA;
/* Prioritäten */
--color-priority-low: #8E8E93;
--color-priority-medium: #FF9500;
--color-priority-high: #FF6B35;
--color-priority-urgent: #FF3B30;
/* Schatten */
--shadow-sm: 0 1px 3px rgba(0,0,0,0.08);
--shadow-md: 0 4px 12px rgba(0,0,0,0.1);
--shadow-lg: 0 8px 24px rgba(0,0,0,0.12);
/* Radien */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
/* Typografie */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', monospace;
}
/* Dark Mode */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1C1C1E;
--color-surface: #2C2C2E;
--color-border: #3A3A3C;
--color-text-primary: #F5F5F7;
--color-text-secondary: #8E8E93;
}
}
```
### Typografie
- Überschriften: System Font Stack, font-weight 600700
- Body: 16px (mobile), 15px (desktop), line-height 1.5
- Small/Caption: 13px, `color: var(--color-text-secondary)`
### Komponenten-Standards
- **Cards:** `background: var(--color-surface)`, `border-radius: var(--radius-md)`, `box-shadow: var(--shadow-sm)`
- **Buttons:** Primär = `var(--color-accent)` + weiße Schrift. Sekundär = Outline. Mindesthöhe 44px (Touch-Target).
- **Inputs:** `border-radius: var(--radius-sm)`, `border: 1.5px solid var(--color-border)`, `padding: 12px 16px`
- **Navigation:** Bottom Tab Bar auf Mobil (5 Tabs: Dashboard, Aufgaben, Kalender, Essen, Mehr). Sidebar auf Desktop.
- **Übergänge:** `transition: all 0.2s ease` für interaktive Elemente. Seiten-Übergänge: Slide-Animation.
- **Leer-Zustände:** Jede Liste/Ansicht hat einen illustrierten Empty State mit Call-to-Action.
- **Touch:** Haptic Feedback wo möglich (`navigator.vibrate`). Pull-to-Refresh auf Listen.
### Responsive Breakpoints
- Mobil: < 768px (1 Spalte, Bottom Nav)
- Tablet: 768px1024px (2 Spalten, Bottom Nav)
- Desktop: > 1024px (Sidebar + Content Area)
</design_system>
---
## ENTWICKLUNGSPLAN — PHASENSTRUKTUR
<development_phases>
### Phase 1: Fundament
1. Projektstruktur anlegen (Verzeichnisse, package.json, .env.example, .gitignore)
2. Express-Server mit grundlegendem Routing-Setup
3. SQLite + SQLCipher Datenbankverbindung + Schema-Migration
4. Auth-System (Login, Session, User-CRUD für Admin)
5. Frontend App-Shell (SPA-Router, Navigation, Layout-Gerüst)
6. CSS Design-System (Custom Properties, Basis-Komponenten)
7. Docker Compose + Nginx-Config
### Phase 2: Kern-Module
8. Dashboard (Layout + Widget-Slots, zunächst mit Platzhaltern)
9. Aufgaben-Modul (CRUD + Listenansicht + Teilaufgaben)
10. Einkaufslisten-Modul (CRUD + Kategorien + Checkbox-Logik)
11. Essensplan-Modul (Wochenansicht + CRUD + Zutaten)
12. Essensplan → Einkaufsliste Integration
### Phase 3: Kalender & Erweiterungen
13. Kalender-Modul (lokale Termine, Monats-/Wochen-/Tagesansicht)
14. Google Calendar OAuth + Sync
15. Apple Calendar CalDAV + Sync
16. Pinnwand-Modul
17. Kontakte-Modul
18. Budget-Tracker
### Phase 4: Polish & Integration
19. Dashboard-Widgets mit Live-Daten verbinden
20. Wetter-Widget (OpenWeatherMap-Integration)
21. Wiederkehrende Aufgaben + Termine (RRULE-Engine)
22. Kanban-Ansicht für Aufgaben
23. Dark Mode
24. PWA (Service Worker, Manifest, Offline-Shell)
25. Drag & Drop (Aufgaben, Essensplan)
26. Swipe-Gesten (Mobil)
### Phase 5: Härtung
27. Input-Validation + Sanitization auf allen Endpoints
28. CSRF-Protection
29. Rate Limiting
30. CSP-Header + Security-Audit
31. Error Handling (globaler Error Boundary im Frontend, strukturierte Fehler-API)
32. Performance-Optimierung (Lazy Loading, Caching-Strategie)
33. README.md mit Setup-Anleitung
</development_phases>
---
## ANWEISUNGEN FÜR CLAUDE CODE
<execution_rules>
1. **Arbeite Phase für Phase.** Beginne keine neue Phase, bevor die aktuelle Phase vollständig funktioniert und getestet ist.
2. **Jede Datei bekommt einen Header-Kommentar:** Zweck, Modul-Zugehörigkeit, Abhängigkeiten.
3. **Kein toter Code.** Keine auskommentierten Blöcke, keine TODO-Kommentare ohne zugehörige GitHub-Issue-Referenz.
4. **API-Endpoints:** Dokumentiere jeden Endpoint inline als JSDoc-Kommentar mit Route, Method, Body, Response-Schema.
5. **Fehlerbehandlung:** Jeder API-Endpoint hat try/catch. Fehler werden als `{ error: string, code: number }` zurückgegeben.
6. **Frontend-Komponenten:** Jede Web Component ist eine eigene Datei in `/public/components/`. Naming: `fb-[modul]-[name].js` (z.B. `fb-task-card.js`).
7. **CSS:** Eine globale `styles.css` mit Design Tokens + Reset. Pro Modul eine eigene CSS-Datei. Kein Inline-Styling.
8. **Dateistruktur:**
```
/oikos
├── server/
│ ├── index.js (Express Entry Point)
│ ├── db.js (SQLite/SQLCipher Setup + Migrations)
│ ├── auth.js (Auth Middleware + Routes)
│ ├── routes/
│ │ ├── tasks.js
│ │ ├── shopping.js
│ │ ├── meals.js
│ │ ├── calendar.js
│ │ ├── notes.js
│ │ ├── contacts.js
│ │ ├── budget.js
│ │ └── weather.js
│ └── services/
│ ├── google-calendar.js
│ ├── apple-calendar.js
│ └── recurrence.js (RRULE-Parser/Generator)
├── public/
│ ├── index.html (App Shell)
│ ├── router.js (Client-Side Router)
│ ├── api.js (Fetch-Wrapper mit Auth + Error Handling)
│ ├── styles/
│ │ ├── tokens.css
│ │ ├── reset.css
│ │ ├── layout.css
│ │ └── [modul].css
│ ├── components/
│ │ ├── fb-app-shell.js
│ │ ├── fb-nav-bar.js
│ │ ├── fb-dashboard.js
│ │ ├── fb-task-*.js
│ │ ├── fb-shopping-*.js
│ │ ├── fb-meal-*.js
│ │ ├── fb-calendar-*.js
│ │ ├── fb-notes-*.js
│ │ ├── fb-contacts-*.js
│ │ └── fb-budget-*.js
│ ├── pages/
│ │ ├── dashboard.js
│ │ ├── tasks.js
│ │ ├── shopping.js
│ │ ├── meals.js
│ │ ├── calendar.js
│ │ ├── notes.js
│ │ ├── contacts.js
│ │ ├── budget.js
│ │ ├── settings.js
│ │ └── login.js
│ ├── sw.js (Service Worker)
│ └── manifest.json
├── docker-compose.yml
├── Dockerfile
├── nginx.conf.example
├── .env.example
├── package.json
└── README.md
```
9. **Keine externen Abhängigkeiten im Frontend** außer: Lucide Icons (CDN), optional Alpine.js (CDN).
10. **Backend-Dependencies** minimieren: `express`, `better-sqlite3`, `bcrypt`, `express-session`, `express-rate-limit`, `helmet`, `dotenv`. Für Kalender-Sync: `node-fetch`, `googleapis` (Google), `tsdav` (CalDAV).
11. **Deutsche UI-Texte.** Alle Labels, Buttons, Meldungen auf Deutsch. Datumsformate: `DD.MM.YYYY`, Uhrzeiten: `HH:MM` (24h).
12. **Bei Architektur-Unklarheiten:** Nicht raten. Frage nach. Dokumentiere die Entscheidung als Kommentar.
</execution_rules>
---
## QUALITÄTSKRITERIEN
<success_criteria>
- [ ] App startet mit `docker compose up` ohne manuelle Schritte außer `.env` konfigurieren
- [ ] Login funktioniert, Session bleibt über Browser-Neustart erhalten
- [ ] Alle 8 Module (Dashboard, Aufgaben, Einkauf, Essen, Kalender, Pinnwand, Kontakte, Budget) sind CRUD-funktional
- [ ] Essensplan-Zutaten können auf Einkaufsliste übernommen werden
- [ ] Dashboard zeigt Live-Daten aus allen relevanten Modulen
- [ ] Wetter-Widget zeigt aktuelle Daten
- [ ] App ist auf iPhone SE (375px) voll bedienbar
- [ ] Dark Mode funktioniert systemgesteuert
- [ ] Datenbank ist verschlüsselt (SQLCipher)
- [ ] Kein API-Endpoint ist ohne Auth erreichbar (außer Login)
- [ ] Lighthouse Mobile Score: Performance > 85, Accessibility > 90, Best Practices > 90
- [ ] Google Calendar Sync funktioniert bidirektional
</success_criteria>
+27
View File
@@ -0,0 +1,27 @@
FROM node:20-slim
# SQLCipher-Abhängigkeiten
RUN apt-get update && apt-get install -y \
python3 \
make \
g++ \
libsqlcipher-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Abhängigkeiten zuerst (Docker-Layer-Caching)
COPY package*.json ./
RUN npm ci --omit=dev
# Anwendungscode
COPY . .
# Daten-Volume-Verzeichnis
RUN mkdir -p /data
EXPOSE 3000
USER node
CMD ["node", "server/index.js"]
+26
View File
@@ -0,0 +1,26 @@
version: "3.9"
services:
oikos:
build: .
container_name: oikos
restart: unless-stopped
ports:
- "3000:3000"
volumes:
- oikos_data:/data
env_file:
- .env
environment:
- NODE_ENV=production
- DB_PATH=/data/oikos.db
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/health', r => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
oikos_data:
driver: local
+48
View File
@@ -0,0 +1,48 @@
# Nginx Reverse Proxy Konfiguration für Oikos
# Für Nginx Proxy Manager: Diese Datei als Vorlage für "Advanced"-Konfiguration nutzen.
# Ersetze "deine-domain.de" mit deiner tatsächlichen Domain.
server {
listen 80;
server_name deine-domain.de;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name deine-domain.de;
# SSL (von Nginx Proxy Manager automatisch verwaltet, oder manuell):
# ssl_certificate /etc/letsencrypt/live/deine-domain.de/fullchain.pem;
# ssl_certificate_key /etc/letsencrypt/live/deine-domain.de/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security Headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Proxy zu Oikos
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Statische Assets cachen
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2)$ {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
expires 7d;
add_header Cache-Control "public, immutable";
}
}
+3028
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "oikos",
"version": "1.0.0",
"description": "Selbstgehostete Familienplaner-Web-App",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
"dev": "node --watch server/index.js",
"setup": "node setup.js",
"test:db": "node --experimental-sqlite test-db.js"
},
"dependencies": {
"bcrypt": "^5.1.1",
"better-sqlite3": "^9.6.0",
"connect-sqlite3": "^0.9.15",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-session": "^1.18.1",
"helmet": "^8.0.0",
"node-fetch": "^3.3.2"
},
"optionalDependencies": {
"googleapis": "^144.0.0",
"tsdav": "^2.0.10"
},
"engines": {
"node": ">=20.0.0"
}
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Modul: API-Client
* Zweck: Fetch-Wrapper mit Session-Auth, einheitlicher Fehlerbehandlung und JSON-Parsing
* Abhängigkeiten: keine
*/
const API_BASE = '/api/v1';
/**
* Zentraler Fetch-Wrapper.
* Setzt Content-Type, handhabt 401-Redirects und parsed JSON-Fehler.
*
* @param {string} path - API-Pfad ohne /api/v1 (z.B. '/tasks')
* @param {RequestInit} options - Fetch-Optionen
* @returns {Promise<any>} Geparstes JSON oder wirft einen Fehler
*/
async function apiFetch(path, options = {}) {
const url = `${API_BASE}${path}`;
const response = await fetch(url, {
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (response.status === 401) {
// Session abgelaufen → zur Login-Seite
window.dispatchEvent(new CustomEvent('auth:expired'));
throw new Error('Sitzung abgelaufen.');
}
const data = await response.json().catch(() => null);
if (!response.ok) {
const message = data?.error || `HTTP ${response.status}`;
throw new ApiError(message, response.status, data);
}
return data;
}
/**
* Strukturierter API-Fehler mit HTTP-Status-Code.
*/
class ApiError extends Error {
constructor(message, status, data = null) {
super(message);
this.name = 'ApiError';
this.status = status;
this.data = data;
}
}
// --------------------------------------------------------
// Convenience-Methoden
// --------------------------------------------------------
const api = {
get: (path) => apiFetch(path, { method: 'GET' }),
post: (path, body) => apiFetch(path, {
method: 'POST',
body: JSON.stringify(body),
}),
put: (path, body) => apiFetch(path, {
method: 'PUT',
body: JSON.stringify(body),
}),
patch: (path, body) => apiFetch(path, {
method: 'PATCH',
body: JSON.stringify(body),
}),
delete: (path) => apiFetch(path, { method: 'DELETE' }),
};
// --------------------------------------------------------
// Auth-spezifische Methoden
// --------------------------------------------------------
const auth = {
login: (username, password) => api.post('/auth/login', { username, password }),
logout: () => api.post('/auth/logout'),
me: () => api.get('/auth/me'),
getUsers: () => api.get('/auth/users'),
createUser: (data) => api.post('/auth/users', data),
deleteUser: (id) => api.delete(`/auth/users/${id}`),
};
export { api, auth, ApiError };
+47
View File
@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#007AFF" />
<meta name="description" content="Oikos — Familienplaner" />
<title>Oikos</title>
<!-- PWA -->
<link rel="manifest" href="/manifest.json" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<!-- Styles -->
<link rel="stylesheet" href="/styles/tokens.css" />
<link rel="stylesheet" href="/styles/reset.css" />
<link rel="stylesheet" href="/styles/layout.css" />
<link rel="stylesheet" href="/styles/login.css" />
<!-- Lucide Icons (CDN) -->
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
</head>
<body>
<!-- App-Shell — wird durch JavaScript gefüllt -->
<div id="app" class="app-shell">
<!-- Skeleton-Loading während Initialisierung -->
<div id="app-loading" class="app-loading" aria-live="polite" aria-label="Lade Oikos…">
<div class="app-loading__logo">Oikos</div>
</div>
</div>
<!-- Module (ES-Module, kein Bundler) -->
<script type="module" src="/api.js"></script>
<script type="module" src="/router.js"></script>
<!-- Service Worker registrieren -->
<script>
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch((err) => {
console.warn('[SW] Registrierung fehlgeschlagen:', err);
});
});
}
</script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
{
"name": "Oikos Familienplaner",
"short_name": "Oikos",
"description": "Selbstgehosteter Familienplaner für Kalender, Aufgaben, Einkauf und mehr.",
"start_url": "/",
"display": "standalone",
"background_color": "#F5F5F7",
"theme_color": "#007AFF",
"orientation": "portrait-primary",
"lang": "de",
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Budget
* Zweck: Seite für das Budget-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Budget</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Calendar
* Zweck: Seite für das Calendar-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Calendar</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Contacts
* Zweck: Seite für das Contacts-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Contacts</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Dashboard
* Zweck: Seite für das Dashboard-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Dashboard</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+96
View File
@@ -0,0 +1,96 @@
/**
* Modul: Login-Seite
* Zweck: Anmeldeformular mit Username/Passwort, Fehlerbehandlung, Session-Start
* Abhängigkeiten: /api.js
*/
import { auth } from '/api.js';
/**
* Rendert die Login-Seite in den gegebenen Container.
* @param {HTMLElement} container
*/
export async function render(container) {
container.innerHTML = `
<div class="login-page">
<div class="login-card card card--padded">
<h1 class="login-card__title">Oikos</h1>
<p class="login-card__subtitle">Familienplaner</p>
<form class="login-form" id="login-form" novalidate>
<div class="form-group">
<label class="label" for="username">Benutzername</label>
<input
class="input"
type="text"
id="username"
name="username"
autocomplete="username"
autocapitalize="none"
autocorrect="off"
placeholder="benutzername"
required
/>
</div>
<div class="form-group">
<label class="label" for="password">Passwort</label>
<input
class="input"
type="password"
id="password"
name="password"
autocomplete="current-password"
placeholder="••••••••"
required
/>
</div>
<div class="login-error" id="login-error" role="alert" aria-live="polite" hidden></div>
<button type="submit" class="btn btn--primary login-form__submit" id="login-btn">
Anmelden
</button>
</form>
</div>
</div>
`;
const form = container.querySelector('#login-form');
const errorEl = container.querySelector('#login-error');
const submitBtn = container.querySelector('#login-btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
errorEl.hidden = true;
const username = form.username.value.trim();
const password = form.password.value;
if (!username || !password) {
showError(errorEl, 'Bitte alle Felder ausfüllen.');
return;
}
submitBtn.disabled = true;
submitBtn.textContent = 'Wird angemeldet …';
try {
await auth.login(username, password);
window.oikos.navigate('/');
} catch (err) {
showError(errorEl, err.status === 429
? 'Zu viele Versuche. Bitte warte kurz.'
: 'Ungültige Anmeldedaten.'
);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Anmelden';
}
});
}
function showError(el, message) {
el.textContent = message;
el.hidden = false;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Meals
* Zweck: Seite für das Meals-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Meals</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Notes
* Zweck: Seite für das Notes-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Notes</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Settings
* Zweck: Seite für das Settings-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Settings</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Shopping
* Zweck: Seite für das Shopping-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Shopping</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Modul: Tasks
* Zweck: Seite für das Tasks-Modul
* Abhängigkeiten: /api.js
*/
import { api } from '/api.js';
/**
* @param {HTMLElement} container
* @param {{ user: object }} context
*/
export async function render(container, { user }) {
container.innerHTML = `
<div class="page">
<div class="page__header">
<h1 class="page__title">Tasks</h1>
</div>
<div class="empty-state">
<div class="empty-state__title">Kommt bald.</div>
<div class="empty-state__description">Dieses Modul wird in Phase 2 implementiert.</div>
</div>
</div>
`;
}
+237
View File
@@ -0,0 +1,237 @@
/**
* Modul: Client-Side Router
* Zweck: SPA-Routing über History API ohne Framework, Auth-Guard, Seiten-Übergänge
* Abhängigkeiten: api.js
*/
import { auth } from '/api.js';
// --------------------------------------------------------
// Routen-Definitionen
// Jede Route hat: path, page (dynamisch geladen), requiresAuth
// --------------------------------------------------------
const ROUTES = [
{ path: '/login', page: '/pages/login.js', requiresAuth: false },
{ path: '/', page: '/pages/dashboard.js', requiresAuth: true },
{ path: '/tasks', page: '/pages/tasks.js', requiresAuth: true },
{ path: '/shopping', page: '/pages/shopping.js', requiresAuth: true },
{ path: '/meals', page: '/pages/meals.js', requiresAuth: true },
{ path: '/calendar', page: '/pages/calendar.js', requiresAuth: true },
{ path: '/notes', page: '/pages/notes.js', requiresAuth: true },
{ path: '/contacts', page: '/pages/contacts.js', requiresAuth: true },
{ path: '/budget', page: '/pages/budget.js', requiresAuth: true },
{ path: '/settings', page: '/pages/settings.js', requiresAuth: true },
];
// --------------------------------------------------------
// Globaler App-State
// --------------------------------------------------------
let currentUser = null;
let currentPath = null;
// --------------------------------------------------------
// Router
// --------------------------------------------------------
/**
* Navigiert zu einem Pfad und rendert die entsprechende Seite.
* @param {string} path
* @param {boolean} pushState - false beim initialen Load und popstate
*/
async function navigate(path, pushState = true) {
if (path === currentPath) return;
currentPath = path;
const route = ROUTES.find((r) => r.path === path) ?? ROUTES.find((r) => r.path === '/');
// Auth-Guard
if (route.requiresAuth && !currentUser) {
try {
const result = await auth.me();
currentUser = result.user;
} catch {
navigateTo('/login', true);
return;
}
}
if (!route.requiresAuth && currentUser && path === '/login') {
navigateTo('/', true);
return;
}
if (pushState) {
history.pushState({ path }, '', path);
}
await renderPage(route);
updateNav(path);
}
/**
* Lädt und rendert eine Seite dynamisch.
* @param {{ path: string, page: string }} route
*/
async function renderPage(route) {
const app = document.getElementById('app');
const loading = document.getElementById('app-loading');
// Loading verstecken
if (loading) loading.hidden = true;
try {
const module = await import(route.page + '?v=1');
if (typeof module.render !== 'function') {
throw new Error(`Seite ${route.page} exportiert keine render()-Funktion.`);
}
// Seiten-Wrapper erstellen
const pageWrapper = document.createElement('div');
pageWrapper.className = 'page-transition';
pageWrapper.style.animation = 'page-in 0.2s ease forwards';
await module.render(pageWrapper, { user: currentUser });
// Nav + Content einmalig aufbauen (beim ersten Render)
if (!document.querySelector('.nav-bottom') && currentUser) {
renderAppShell(app);
}
const content = document.getElementById('page-content') || app;
content.replaceChildren(pageWrapper);
} catch (err) {
console.error('[Router] Seiten-Render-Fehler:', err);
renderError(app, err);
}
}
/**
* App-Shell mit Navigation einmalig aufbauen (nach erstem Login).
*/
function renderAppShell(container) {
container.innerHTML = `
<nav class="nav-sidebar" aria-label="Hauptnavigation">
<div class="nav-sidebar__logo">Oikos</div>
<div class="nav-sidebar__items" role="list">
${navItems().map(navItemHtml).join('')}
</div>
</nav>
<main class="app-content" id="page-content" aria-live="polite">
</main>
<nav class="nav-bottom" aria-label="Navigation">
<div class="nav-bottom__items" role="list">
${navItems().slice(0, 5).map(navItemHtml).join('')}
</div>
</nav>
<div class="toast-container" id="toast-container" aria-live="assertive"></div>
`;
// Klick-Handler für alle Nav-Links
container.querySelectorAll('[data-route]').forEach((el) => {
el.addEventListener('click', (e) => {
e.preventDefault();
navigate(el.dataset.route);
});
});
}
function navItems() {
return [
{ path: '/', label: 'Übersicht', icon: 'layout-dashboard' },
{ path: '/tasks', label: 'Aufgaben', icon: 'check-square' },
{ path: '/calendar', label: 'Kalender', icon: 'calendar' },
{ path: '/meals', label: 'Essen', icon: 'utensils' },
{ path: '/shopping', label: 'Einkauf', icon: 'shopping-cart' },
{ path: '/notes', label: 'Pinnwand', icon: 'sticky-note' },
{ path: '/contacts', label: 'Kontakte', icon: 'book-user' },
{ path: '/budget', label: 'Budget', icon: 'wallet' },
{ path: '/settings', label: 'Einstellungen', icon: 'settings' },
];
}
function navItemHtml({ path, label, icon }) {
return `
<a href="${path}" data-route="${path}" class="nav-item" role="listitem" aria-label="${label}">
<i data-lucide="${icon}" class="nav-item__icon" aria-hidden="true"></i>
<span class="nav-item__label">${label}</span>
</a>
`;
}
/**
* Aktiven Nav-Link hervorheben.
*/
function updateNav(path) {
document.querySelectorAll('[data-route]').forEach((el) => {
el.removeAttribute('aria-current');
if (el.dataset.route === path) {
el.setAttribute('aria-current', 'page');
}
});
// Lucide Icons neu rendern (nach DOM-Update)
if (window.lucide) {
window.lucide.createIcons();
}
}
function renderError(container, err) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state__title">Etwas ist schiefgelaufen.</div>
<div class="empty-state__description">${err.message}</div>
<button class="btn btn--primary" onclick="location.reload()">Neu laden</button>
</div>
`;
}
// --------------------------------------------------------
// Toast-Benachrichtigungen (global)
// --------------------------------------------------------
/**
* Zeigt eine Toast-Benachrichtigung an.
* @param {string} message
* @param {'default'|'success'|'danger'|'warning'} type
* @param {number} duration - ms
*/
function showToast(message, type = 'default', duration = 3000) {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = `toast ${type !== 'default' ? `toast--${type}` : ''}`;
toast.textContent = message;
toast.setAttribute('role', 'alert');
container.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
// --------------------------------------------------------
// Event-Listener
// --------------------------------------------------------
// Browser zurück/vor
window.addEventListener('popstate', (e) => {
navigate(e.state?.path || location.pathname, false);
});
// Session abgelaufen
window.addEventListener('auth:expired', () => {
currentUser = null;
navigate('/login');
});
// --------------------------------------------------------
// Initialisierung
// --------------------------------------------------------
navigate(location.pathname, false);
// Globale Exporte
window.oikos = { navigate, showToast };
+451
View File
@@ -0,0 +1,451 @@
/**
* Modul: Layout
* Zweck: App-Shell-Layout, Navigation (Bottom Mobile / Sidebar Desktop), Responsive Grid
* Abhängigkeiten: tokens.css, reset.css
*/
/* --------------------------------------------------------
* App-Shell
* -------------------------------------------------------- */
.app-shell {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
/* --------------------------------------------------------
* Loading-Screen
* -------------------------------------------------------- */
.app-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
background-color: var(--color-bg);
}
.app-loading__logo {
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
color: var(--color-accent);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* --------------------------------------------------------
* Layout: Mobile (Standard, < 1024px)
* -------------------------------------------------------- */
.app-content {
flex: 1;
padding-bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom));
overflow-y: auto;
}
/* --------------------------------------------------------
* Bottom Navigation (Mobil + Tablet)
* -------------------------------------------------------- */
.nav-bottom {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom));
padding-bottom: var(--safe-area-inset-bottom);
background-color: var(--color-surface);
border-top: 1px solid var(--color-border);
display: flex;
align-items: center;
z-index: var(--z-nav);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.nav-bottom__items {
display: flex;
width: 100%;
height: 100%;
}
.nav-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-1);
padding: var(--space-2) var(--space-1);
color: var(--color-text-secondary);
transition: color var(--transition-fast);
-webkit-tap-highlight-color: transparent;
min-height: unset; /* Reset des Touch-Target-Minimums für Nav-Items */
}
.nav-item[aria-current="page"] {
color: var(--color-accent);
}
.nav-item__icon {
width: 24px;
height: 24px;
}
.nav-item__label {
font-size: var(--text-xs);
font-weight: var(--font-weight-medium);
}
/* --------------------------------------------------------
* Sidebar Navigation (Desktop, > 1024px)
* -------------------------------------------------------- */
@media (min-width: 1024px) {
.app-shell {
flex-direction: row;
}
.app-content {
flex: 1;
padding-bottom: 0;
margin-left: var(--sidebar-width);
}
.nav-bottom {
display: none;
}
.nav-sidebar {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: var(--sidebar-width);
background-color: var(--color-surface);
border-right: 1px solid var(--color-border);
display: flex;
flex-direction: column;
z-index: var(--z-nav);
padding: var(--space-6) 0;
}
.nav-sidebar__logo {
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
color: var(--color-accent);
padding: 0 var(--space-6) var(--space-6);
border-bottom: 1px solid var(--color-border);
margin-bottom: var(--space-4);
}
.nav-sidebar__items {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: 0 var(--space-3);
flex: 1;
}
.nav-sidebar .nav-item {
flex-direction: row;
justify-content: flex-start;
border-radius: var(--radius-sm);
padding: var(--space-3) var(--space-3);
gap: var(--space-3);
min-height: 44px;
}
.nav-sidebar .nav-item[aria-current="page"] {
background-color: var(--color-accent-light);
color: var(--color-accent);
}
.nav-sidebar .nav-item:hover:not([aria-current="page"]) {
background-color: var(--color-surface-2);
}
.nav-sidebar .nav-item__label {
font-size: var(--text-base);
}
}
/* --------------------------------------------------------
* Seiten-Container
* -------------------------------------------------------- */
.page {
padding: var(--space-4);
max-width: var(--content-max-width);
margin: 0 auto;
}
.page__header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
gap: var(--space-4);
}
.page__title {
font-size: var(--text-2xl);
font-weight: var(--font-weight-bold);
}
@media (min-width: 1024px) {
.page {
padding: var(--space-8);
}
}
/* --------------------------------------------------------
* Cards
* -------------------------------------------------------- */
.card {
background-color: var(--color-surface);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.card--padded {
padding: var(--space-4);
}
/* --------------------------------------------------------
* Buttons
* -------------------------------------------------------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-sm);
font-size: var(--text-base);
font-weight: var(--font-weight-medium);
min-height: 44px;
transition: opacity var(--transition-fast), background-color var(--transition-fast);
cursor: pointer;
white-space: nowrap;
}
.btn--primary {
background-color: var(--color-accent);
color: #ffffff;
}
.btn--primary:hover {
background-color: var(--color-accent-hover);
}
.btn--secondary {
background-color: transparent;
color: var(--color-accent);
border: 1.5px solid var(--color-accent);
}
.btn--danger {
background-color: var(--color-danger);
color: #ffffff;
}
.btn--ghost {
background-color: transparent;
color: var(--color-text-secondary);
}
.btn--ghost:hover {
background-color: var(--color-surface-2);
}
.btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.btn--icon {
padding: var(--space-2);
min-height: 44px;
min-width: 44px;
border-radius: var(--radius-sm);
}
/* FAB (Floating Action Button) */
.fab {
position: fixed;
bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom) + var(--space-4));
right: var(--space-4);
width: 56px;
height: 56px;
border-radius: var(--radius-full);
background-color: var(--color-accent);
color: #ffffff;
box-shadow: var(--shadow-lg);
display: flex;
align-items: center;
justify-content: center;
z-index: calc(var(--z-nav) - 1);
transition: transform var(--transition-fast), box-shadow var(--transition-fast);
}
.fab:hover {
transform: scale(1.05);
box-shadow: var(--shadow-lg);
}
@media (min-width: 1024px) {
.fab {
bottom: var(--space-8);
}
}
/* --------------------------------------------------------
* Form-Elemente
* -------------------------------------------------------- */
.input {
width: 100%;
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-sm);
border: 1.5px solid var(--color-border);
background-color: var(--color-surface);
color: var(--color-text-primary);
font-size: var(--text-base);
transition: border-color var(--transition-fast);
min-height: 44px;
}
.input:focus {
outline: none;
border-color: var(--color-accent);
}
.input::placeholder {
color: var(--color-text-disabled);
}
.label {
display: block;
font-size: var(--text-sm);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
margin-bottom: var(--space-1);
}
.form-group {
display: flex;
flex-direction: column;
gap: var(--space-1);
margin-bottom: var(--space-4);
}
/* --------------------------------------------------------
* Skeleton-Loading
* -------------------------------------------------------- */
.skeleton {
background: linear-gradient(
90deg,
var(--color-border) 25%,
var(--color-surface-2) 50%,
var(--color-border) 75%
);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* --------------------------------------------------------
* Leer-Zustände (Empty States)
* -------------------------------------------------------- */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--space-4);
padding: var(--space-12) var(--space-6);
text-align: center;
}
.empty-state__icon {
width: 64px;
height: 64px;
color: var(--color-text-disabled);
}
.empty-state__title {
font-size: var(--text-lg);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
}
.empty-state__description {
font-size: var(--text-base);
color: var(--color-text-secondary);
max-width: 280px;
}
/* --------------------------------------------------------
* Responsive Grid
* -------------------------------------------------------- */
.grid {
display: grid;
gap: var(--space-4);
grid-template-columns: 1fr;
}
@media (min-width: 768px) {
.grid--2 { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 1024px) {
.grid--3 { grid-template-columns: repeat(3, 1fr); }
}
/* --------------------------------------------------------
* Toast-Benachrichtigungen
* -------------------------------------------------------- */
.toast-container {
position: fixed;
bottom: calc(var(--nav-height-mobile) + var(--safe-area-inset-bottom) + var(--space-4));
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: var(--space-2);
z-index: var(--z-toast);
pointer-events: none;
width: min(calc(100% - var(--space-8)), 400px);
}
@media (min-width: 1024px) {
.toast-container {
bottom: var(--space-6);
left: calc(var(--sidebar-width) + 50%);
}
}
.toast {
background-color: var(--color-text-primary);
color: var(--color-bg);
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
box-shadow: var(--shadow-lg);
pointer-events: auto;
animation: toast-in 0.2s ease forwards;
}
.toast--success { background-color: var(--color-success); color: #fff; }
.toast--danger { background-color: var(--color-danger); color: #fff; }
.toast--warning { background-color: var(--color-warning); color: #fff; }
@keyframes toast-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
+48
View File
@@ -0,0 +1,48 @@
/**
* Modul: Login-Seite
* Zweck: Styles für die Anmeldeseite
* Abhängigkeiten: tokens.css, reset.css, layout.css
*/
.login-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: var(--space-4);
background-color: var(--color-bg);
}
.login-card {
width: 100%;
max-width: 380px;
}
.login-card__title {
font-size: var(--text-3xl);
font-weight: var(--font-weight-bold);
color: var(--color-accent);
text-align: center;
margin-bottom: var(--space-1);
}
.login-card__subtitle {
font-size: var(--text-base);
color: var(--color-text-secondary);
text-align: center;
margin-bottom: var(--space-8);
}
.login-form__submit {
width: 100%;
margin-top: var(--space-2);
}
.login-error {
padding: var(--space-3) var(--space-4);
background-color: var(--color-danger-light);
color: var(--color-danger);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
margin-bottom: var(--space-4);
}
+83
View File
@@ -0,0 +1,83 @@
/**
* Modul: CSS Reset
* Zweck: Browser-Defaults normalisieren, Box-Sizing, Touch-Verhalten
* Abhängigkeiten: tokens.css
*/
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
font-family: var(--font-sans);
font-size: 16px;
line-height: var(--line-height-base);
color: var(--color-text-primary);
background-color: var(--color-bg);
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
scroll-behavior: smooth;
}
body {
min-height: 100dvh;
overflow-x: hidden;
}
img, svg, video {
display: block;
max-width: 100%;
}
button {
font: inherit;
cursor: pointer;
border: none;
background: none;
}
input, textarea, select {
font: inherit;
color: inherit;
}
a {
color: inherit;
text-decoration: none;
}
ul, ol {
list-style: none;
}
h1, h2, h3, h4, h5, h6 {
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
}
/* Touch-Targets: Mindestgröße 44px */
button, [role="button"], input[type="checkbox"], input[type="radio"] {
min-height: 44px;
}
/* Fokus-Styles (Accessibility) */
:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-xs);
}
/* Versteckte, aber zugängliche Elemente */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+149
View File
@@ -0,0 +1,149 @@
/**
* Modul: Design Tokens
* Zweck: CSS Custom Properties für das gesamte Design-System
* Abhängigkeiten: keine
*/
:root {
/* --------------------------------------------------------
* Farben — Neutrals
* -------------------------------------------------------- */
--color-bg: #F5F5F7;
--color-surface: #FFFFFF;
--color-surface-2: #F0F0F5;
--color-border: #E5E5EA;
--color-text-primary: #1C1C1E;
--color-text-secondary: #8E8E93;
--color-text-disabled: #C7C7CC;
/* --------------------------------------------------------
* Farben — Akzent (konfigurierbar)
* -------------------------------------------------------- */
--color-accent: #007AFF;
--color-accent-hover: #0056CC;
--color-accent-light: #E3F2FF;
/* --------------------------------------------------------
* Farben — Semantisch
* -------------------------------------------------------- */
--color-success: #34C759;
--color-success-light: #E3F9EB;
--color-warning: #FF9500;
--color-warning-light: #FFF3E0;
--color-danger: #FF3B30;
--color-danger-light: #FFE5E3;
--color-info: #5AC8FA;
--color-info-light: #E5F7FF;
/* --------------------------------------------------------
* Farben — Prioritäten
* -------------------------------------------------------- */
--color-priority-low: #8E8E93;
--color-priority-medium: #FF9500;
--color-priority-high: #FF6B35;
--color-priority-urgent: #FF3B30;
/* --------------------------------------------------------
* Schatten
* -------------------------------------------------------- */
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.10);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
/* --------------------------------------------------------
* Border-Radien
* -------------------------------------------------------- */
--radius-xs: 4px;
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-full: 9999px;
/* --------------------------------------------------------
* Typografie
* -------------------------------------------------------- */
--font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.8125rem; /* 13px */
--text-base: 1rem; /* 16px */
--text-md: 1.0625rem; /* 17px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--line-height-tight: 1.2;
--line-height-base: 1.5;
--line-height-relaxed: 1.75;
--font-weight-regular: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* --------------------------------------------------------
* Abstände
* -------------------------------------------------------- */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* --------------------------------------------------------
* Layout
* -------------------------------------------------------- */
--nav-height-mobile: 64px;
--sidebar-width: 240px;
--content-max-width: 1200px;
--safe-area-inset-bottom: env(safe-area-inset-bottom, 0px);
/* --------------------------------------------------------
* Übergänge
* -------------------------------------------------------- */
--transition-fast: 0.1s ease;
--transition-base: 0.2s ease;
--transition-slow: 0.3s ease;
/* --------------------------------------------------------
* Z-Indizes
* -------------------------------------------------------- */
--z-base: 0;
--z-card: 1;
--z-nav: 100;
--z-modal: 200;
--z-toast: 300;
}
/* --------------------------------------------------------
* Dark Mode
* -------------------------------------------------------- */
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #1C1C1E;
--color-surface: #2C2C2E;
--color-surface-2: #3A3A3C;
--color-border: #3A3A3C;
--color-text-primary: #F5F5F7;
--color-text-secondary: #8E8E93;
--color-text-disabled: #48484A;
--color-accent-light: #1A3A5C;
--color-success-light: #1A3A26;
--color-warning-light: #3A2800;
--color-danger-light: #3A1A1A;
--color-info-light: #1A3A4A;
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Modul: Service Worker
* Zweck: Offline-Fähigkeit (App-Shell-Caching), Hintergrund-Sync
* Abhängigkeiten: keine
*/
const CACHE_NAME = 'oikos-v1';
// App-Shell-Ressourcen, die offline verfügbar sein sollen
const APP_SHELL = [
'/',
'/index.html',
'/api.js',
'/router.js',
'/styles/tokens.css',
'/styles/reset.css',
'/styles/layout.css',
'/styles/login.css',
'/manifest.json',
];
// --------------------------------------------------------
// Install: App-Shell cachen
// --------------------------------------------------------
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL))
);
self.skipWaiting();
});
// --------------------------------------------------------
// Activate: Alte Caches löschen
// --------------------------------------------------------
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key))
)
)
);
self.clients.claim();
});
// --------------------------------------------------------
// Fetch: Netzwerk-First für API, Cache-First für App-Shell
// --------------------------------------------------------
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// API-Requests: immer Netzwerk (kein Caching von Nutzerdaten)
if (url.pathname.startsWith('/api/')) {
return; // Browser übernimmt
}
// App-Shell: Cache-First, Fallback Netzwerk
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (response.ok && response.type === 'basic') {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
}
return response;
}).catch(() => {
// Offline-Fallback für Seiten-Navigation
if (request.mode === 'navigate') {
return caches.match('/index.html');
}
});
})
);
});
+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: () => [],
};
+136
View File
@@ -0,0 +1,136 @@
/**
* Modul: Setup-Script
* Zweck: Erstmalige Einrichtung — ersten Admin-User anlegen.
* Wird einmalig nach dem ersten Start ausgeführt: `node setup.js`
* Abhängigkeiten: server/db.js, bcrypt, dotenv
*/
'use strict';
require('dotenv').config();
const readline = require('node:readline');
const bcrypt = require('bcrypt');
const db = require('./server/db');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
function prompt(question) {
return new Promise((resolve) => rl.question(question, resolve));
}
function promptPassword(question) {
return new Promise((resolve) => {
process.stdout.write(question);
process.stdin.setRawMode(true);
process.stdin.resume();
let password = '';
process.stdin.on('data', function handler(char) {
char = char.toString();
if (char === '\r' || char === '\n') {
process.stdin.setRawMode(false);
process.stdin.removeListener('data', handler);
process.stdout.write('\n');
resolve(password);
} else if (char === '\u0003') {
process.exit();
} else if (char === '\u007f') {
if (password.length > 0) {
password = password.slice(0, -1);
process.stdout.write('\b \b');
}
} else {
password += char;
process.stdout.write('*');
}
});
});
}
async function main() {
console.log('\n=== Oikos Setup ===\n');
// Datenbank initialisieren
db.init();
// Prüfen ob bereits Admin vorhanden
const existingAdmin = db.get()
.prepare("SELECT id FROM users WHERE role = 'admin' LIMIT 1")
.get();
if (existingAdmin) {
console.log(' Es existiert bereits ein Admin-Account.\n');
const proceed = await prompt('Trotzdem einen weiteren Admin anlegen? (j/N): ');
if (proceed.toLowerCase() !== 'j') {
console.log('Setup abgebrochen.');
rl.close();
process.exit(0);
}
}
console.log('Admin-Account anlegen:\n');
const username = (await prompt('Benutzername: ')).trim();
if (!username || username.length < 3) {
console.error('Fehler: Benutzername muss mindestens 3 Zeichen lang sein.');
process.exit(1);
}
const displayName = (await prompt('Anzeigename (z.B. "Max Mustermann"): ')).trim();
if (!displayName) {
console.error('Fehler: Anzeigename darf nicht leer sein.');
process.exit(1);
}
const password = await promptPassword('Passwort: ');
if (password.length < 8) {
console.error('Fehler: Passwort muss mindestens 8 Zeichen lang sein.');
process.exit(1);
}
const passwordConfirm = await promptPassword('Passwort bestätigen: ');
if (password !== passwordConfirm) {
console.error('Fehler: Passwörter stimmen nicht überein.');
process.exit(1);
}
const avatarColors = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#FF2D55'];
const avatarColor = avatarColors[Math.floor(Math.random() * avatarColors.length)];
console.log('\nAccount wird erstellt …');
const hash = await bcrypt.hash(password, 12);
try {
const result = db.get()
.prepare(`
INSERT INTO users (username, display_name, password_hash, avatar_color, role)
VALUES (?, ?, ?, ?, 'admin')
`)
.run(username, displayName, hash, avatarColor);
console.log(`\n✓ Admin-Account erstellt (ID: ${result.lastInsertRowid})`);
console.log(` Benutzername: ${username}`);
console.log(` Anzeigename: ${displayName}`);
console.log(` Rolle: admin`);
console.log('\nDu kannst dich jetzt unter /login anmelden.\n');
} catch (err) {
if (err.message?.includes('UNIQUE constraint')) {
console.error(`\nFehler: Benutzername "${username}" ist bereits vergeben.`);
} else {
console.error('\nFehler beim Erstellen:', err.message);
}
process.exit(1);
}
rl.close();
process.exit(0);
}
main().catch((err) => {
console.error('Unerwarteter Fehler:', err.message);
process.exit(1);
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Modul: Datenbank-Test
* Zweck: Schema-Migration mit node:sqlite (built-in) validieren.
* Kein Kompilieren nötig — läuft direkt mit Node 22+.
* Testet SQL-Korrektheit, FK-Reihenfolge, Triggers, Indizes.
*
* Ausführen: node test-db.js
*/
'use strict';
const { DatabaseSync } = require('node:sqlite');
// --------------------------------------------------------
// Migrations-SQL direkt aus db.js extrahieren
// (Nur für Tests — in Produktion läuft db.js mit better-sqlite3)
// --------------------------------------------------------
const { MIGRATIONS_SQL } = require('./server/db-schema-test');
let passed = 0;
let failed = 0;
function test(name, fn) {
try {
fn();
console.log(`${name}`);
passed++;
} catch (err) {
console.error(`${name}`);
console.error(` ${err.message}`);
failed++;
}
}
function assert(condition, message) {
if (!condition) throw new Error(message || 'Assertion fehlgeschlagen');
}
// --------------------------------------------------------
// Datenbank in Memory aufbauen
// --------------------------------------------------------
const db = new DatabaseSync(':memory:');
db.exec('PRAGMA foreign_keys = ON;');
console.log('\n[DB-Test] Schema-Migration\n');
// --------------------------------------------------------
// Test 1: Migrations-Tabelle anlegen
// --------------------------------------------------------
test('schema_migrations Tabelle erstellen', () => {
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 count = db.prepare('SELECT count(*) as n FROM schema_migrations').get();
assert(count.n === 0, 'Tabelle sollte leer sein');
});
// --------------------------------------------------------
// Test 2: Vollständige Migration v1 ausführen
// --------------------------------------------------------
test('Migration v1 ausführen (alle Tabellen und Triggers)', () => {
db.exec(MIGRATIONS_SQL[1]);
db.prepare('INSERT INTO schema_migrations (version, description) VALUES (1, ?)').run('Initiales Schema');
const v = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
assert(v.v === 1, 'Version sollte 1 sein');
});
// --------------------------------------------------------
// Test 3: Alle erwarteten Tabellen vorhanden
// --------------------------------------------------------
const EXPECTED_TABLES = [
'users', 'tasks', 'shopping_lists', 'shopping_items',
'meals', 'meal_ingredients', 'calendar_events',
'notes', 'contacts', 'budget_entries',
];
EXPECTED_TABLES.forEach((table) => {
test(`Tabelle "${table}" existiert`, () => {
const row = db.prepare(
"SELECT name FROM sqlite_master WHERE type='table' AND name=?"
).get(table);
assert(row, `Tabelle "${table}" nicht gefunden`);
});
});
// --------------------------------------------------------
// Test 4: Alle updated_at-Triggers vorhanden
// --------------------------------------------------------
const EXPECTED_TRIGGERS = EXPECTED_TABLES.filter((t) => t !== 'schema_migrations').map(
(t) => `trg_${t}_updated_at`
);
EXPECTED_TRIGGERS.forEach((trigger) => {
test(`Trigger "${trigger}" existiert`, () => {
const row = db.prepare(
"SELECT name FROM sqlite_master WHERE type='trigger' AND name=?"
).get(trigger);
assert(row, `Trigger "${trigger}" nicht gefunden`);
});
});
// --------------------------------------------------------
// Test 5: CRUD-Operationen
// --------------------------------------------------------
test('User anlegen', () => {
const result = db.prepare(`
INSERT INTO users (username, display_name, password_hash, role)
VALUES ('admin', 'Admin', '$2b$12$test', 'admin')
`).run();
assert(result.lastInsertRowid === 1, 'User-ID sollte 1 sein');
});
test('Aufgabe anlegen und lesen', () => {
const ins = db.prepare(`
INSERT INTO tasks (title, created_by, priority) VALUES ('Testaufgabe', 1, 'high')
`).run();
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(ins.lastInsertRowid);
assert(task.title === 'Testaufgabe', 'Titel stimmt nicht');
assert(task.status === 'open', 'Status sollte open sein');
assert(task.priority === 'high', 'Priorität stimmt nicht');
});
test('Mahlzeit und Einkaufsartikel mit FK-Referenz', () => {
// Mahlzeit zuerst (FK-Reihenfolge)
const meal = db.prepare(`
INSERT INTO meals (date, meal_type, title, created_by) VALUES ('2026-03-24', 'dinner', 'Pizza', 1)
`).run();
const list = db.prepare(`
INSERT INTO shopping_lists (name, created_by) VALUES ('REWE', 1)
`).run();
// Artikel mit Referenz auf Mahlzeit
db.prepare(`
INSERT INTO shopping_items (list_id, name, added_from_meal) VALUES (?, 'Mehl', ?)
`).run(list.lastInsertRowid, meal.lastInsertRowid);
const item = db.prepare('SELECT * FROM shopping_items WHERE name = ?').get('Mehl');
assert(item.added_from_meal === meal.lastInsertRowid, 'FK zu meals stimmt nicht');
});
test('updated_at Trigger feuert bei UPDATE', () => {
const before = db.prepare('SELECT updated_at FROM tasks WHERE id = 1').get();
// Kurz warten damit Timestamp sich unterscheidet
const start = Date.now();
while (Date.now() - start < 1100) { /* busy wait 1s */ }
db.prepare("UPDATE tasks SET title = 'Geändert' WHERE id = 1").run();
const after = db.prepare('SELECT updated_at FROM tasks WHERE id = 1').get();
assert(after.updated_at > before.updated_at, 'updated_at sollte nach UPDATE neuer sein');
});
test('FK ON DELETE CASCADE (User löschen → Aufgaben weg)', () => {
// Zweiten User mit Aufgabe anlegen
db.prepare(`INSERT INTO users (username, display_name, password_hash) VALUES ('user2', 'User 2', 'x')`).run();
db.prepare(`INSERT INTO tasks (title, created_by) VALUES ('Zu löschen', 2)`).run();
db.prepare('DELETE FROM users WHERE id = 2').run();
const orphan = db.prepare("SELECT * FROM tasks WHERE title = 'Zu löschen'").get();
assert(!orphan, 'Verwaiste Aufgaben sollten gelöscht sein');
});
test('CHECK constraint: ungültige Priorität wird abgelehnt', () => {
let threw = false;
try {
db.prepare("INSERT INTO tasks (title, created_by, priority) VALUES ('x', 1, 'invalid')").run();
} catch {
threw = true;
}
assert(threw, 'CHECK constraint sollte Fehler werfen');
});
test('Idempotenz: Migration zweimal ausführen ändert nichts', () => {
// CREATE TABLE IF NOT EXISTS + CREATE TRIGGER IF NOT EXISTS müssen idempotent sein
db.exec(MIGRATIONS_SQL[1]);
const tables = db.prepare("SELECT count(*) as n FROM sqlite_master WHERE type='table'").get();
assert(tables.n > 0, 'Tabellen sollten noch vorhanden sein');
});
// --------------------------------------------------------
// Ergebnis
// --------------------------------------------------------
console.log(`\n[DB-Test] Ergebnis: ${passed} bestanden, ${failed} fehlgeschlagen\n`);
if (failed > 0) process.exit(1);