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:
@@ -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
@@ -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
|
||||
@@ -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 (2–6 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 3–5 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 2–3 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 (Mo–So), 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 600–700
|
||||
- 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: 768px–1024px (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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Generated
+3028
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Modul: Authentifizierung (Auth)
|
||||
* Zweck: Login-Route, Session-Middleware, Auth-Guard für geschützte Routen
|
||||
* Abhängigkeiten: express, bcrypt, express-session, connect-sqlite3, server/db.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcrypt');
|
||||
const session = require('express-session');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const db = require('./db');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Session-Store (SQLite)
|
||||
// --------------------------------------------------------
|
||||
const SQLiteStore = require('connect-sqlite3')(session);
|
||||
|
||||
const sessionStore = new SQLiteStore({
|
||||
db: 'sessions.db',
|
||||
dir: process.env.DB_PATH ? require('path').dirname(process.env.DB_PATH) : '.',
|
||||
ttl: 60 * 60 * 24 * 7, // 7 Tage in Sekunden
|
||||
});
|
||||
|
||||
/**
|
||||
* Session-Middleware konfigurieren.
|
||||
* Wird in server/index.js eingebunden.
|
||||
*/
|
||||
const sessionMiddleware = session({
|
||||
store: sessionStore,
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-AENDERN-IN-PRODUKTION',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
name: 'oikos.sid',
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 Tage in ms
|
||||
},
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Rate Limiting für Login
|
||||
// --------------------------------------------------------
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 60_000,
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_ATTEMPTS) || 5,
|
||||
skipSuccessfulRequests: true,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: 'Zu viele Login-Versuche. Bitte warte kurz.', code: 429 },
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Auth-Guard Middleware
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Prüft ob der Request authentifiziert ist.
|
||||
* Schützt alle API-Routen außer /auth/login.
|
||||
*/
|
||||
function requireAuth(req, res, next) {
|
||||
if (req.session && req.session.userId) {
|
||||
return next();
|
||||
}
|
||||
res.status(401).json({ error: 'Nicht authentifiziert.', code: 401 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob der authentifizierte User Admin-Rolle hat.
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
if (req.session && req.session.role === 'admin') {
|
||||
return next();
|
||||
}
|
||||
res.status(403).json({ error: 'Keine Berechtigung.', code: 403 });
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Routen
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/login
|
||||
* Body: { username: string, password: string }
|
||||
* Response: { user: { id, username, display_name, avatar_color, role } }
|
||||
*/
|
||||
router.post('/login', loginLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'Benutzername und Passwort erforderlich.', code: 400 });
|
||||
}
|
||||
|
||||
const user = db.get().prepare('SELECT * FROM users WHERE username = ?').get(username);
|
||||
|
||||
if (!user) {
|
||||
// Timing-Attack-Schutz: trotzdem bcrypt ausführen
|
||||
await bcrypt.compare(password, '$2b$12$invalidhashfortimingprotection000000000000000000000');
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten.', code: 401 });
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password_hash);
|
||||
if (!valid) {
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten.', code: 401 });
|
||||
}
|
||||
|
||||
req.session.regenerate((err) => {
|
||||
if (err) {
|
||||
console.error('[Auth] Session-Regenerierung fehlgeschlagen:', err);
|
||||
return res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
|
||||
req.session.userId = user.id;
|
||||
req.session.role = user.role;
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.display_name,
|
||||
avatar_color: user.avatar_color,
|
||||
role: user.role,
|
||||
},
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Auth] Login-Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/logout
|
||||
* Response: { ok: true }
|
||||
*/
|
||||
router.post('/logout', requireAuth, (req, res) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('[Auth] Logout-Fehler:', err);
|
||||
return res.status(500).json({ error: 'Logout fehlgeschlagen.', code: 500 });
|
||||
}
|
||||
res.clearCookie('oikos.sid');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/me
|
||||
* Response: { user: { id, username, display_name, avatar_color, role } }
|
||||
*/
|
||||
router.get('/me', requireAuth, (req, res) => {
|
||||
try {
|
||||
const user = db.get()
|
||||
.prepare('SELECT id, username, display_name, avatar_color, role FROM users WHERE id = ?')
|
||||
.get(req.session.userId);
|
||||
|
||||
if (!user) {
|
||||
req.session.destroy(() => {});
|
||||
return res.status(401).json({ error: 'Benutzer nicht gefunden.', code: 401 });
|
||||
}
|
||||
|
||||
res.json({ user });
|
||||
} catch (err) {
|
||||
console.error('[Auth] /me Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/auth/users
|
||||
* Admin only. Listet alle Familienmitglieder.
|
||||
* Response: { data: User[] }
|
||||
*/
|
||||
router.get('/users', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const users = db.get()
|
||||
.prepare('SELECT id, username, display_name, avatar_color, role, created_at FROM users ORDER BY display_name')
|
||||
.all();
|
||||
res.json({ data: users });
|
||||
} catch (err) {
|
||||
console.error('[Auth] Users-Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/auth/users
|
||||
* Admin only. Erstellt neues Familienmitglied.
|
||||
* Body: { username, display_name, password, avatar_color?, role? }
|
||||
* Response: { user: { id, username, display_name, avatar_color, role } }
|
||||
*/
|
||||
router.post('/users', requireAuth, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { username, display_name, password, avatar_color = '#007AFF', role = 'member' } = req.body;
|
||||
|
||||
if (!username || !display_name || !password) {
|
||||
return res.status(400).json({ error: 'Benutzername, Anzeigename und Passwort erforderlich.', code: 400 });
|
||||
}
|
||||
|
||||
if (!['admin', 'member'].includes(role)) {
|
||||
return res.status(400).json({ error: 'Ungültige Rolle.', code: 400 });
|
||||
}
|
||||
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
|
||||
const result = db.get()
|
||||
.prepare(`
|
||||
INSERT INTO users (username, display_name, password_hash, avatar_color, role)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`)
|
||||
.run(username, display_name, hash, avatar_color, role);
|
||||
|
||||
res.status(201).json({
|
||||
user: { id: result.lastInsertRowid, username, display_name, avatar_color, role },
|
||||
});
|
||||
} catch (err) {
|
||||
if (err.message && err.message.includes('UNIQUE constraint')) {
|
||||
return res.status(409).json({ error: 'Benutzername bereits vergeben.', code: 409 });
|
||||
}
|
||||
console.error('[Auth] User-Erstellen-Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/auth/users/:id
|
||||
* Admin only. Löscht ein Familienmitglied.
|
||||
* Response: { ok: true }
|
||||
*/
|
||||
router.delete('/users/:id', requireAuth, requireAdmin, (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id, 10);
|
||||
|
||||
if (userId === req.session.userId) {
|
||||
return res.status(400).json({ error: 'Eigenes Konto kann nicht gelöscht werden.', code: 400 });
|
||||
}
|
||||
|
||||
const result = db.get().prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden.', code: 404 });
|
||||
}
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
console.error('[Auth] User-Löschen-Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { router, sessionMiddleware, requireAuth, requireAdmin };
|
||||
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* Modul: DB-Schema-Export für Tests
|
||||
* Zweck: SQL-Strings aus MIGRATIONS für node:sqlite-Tests exportieren.
|
||||
* Nur für Testzwecke — db.js nutzt die MIGRATIONS direkt intern.
|
||||
* Abhängigkeiten: keine
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
// SQL-String für Migration v1 (gespiegelt aus db.js MIGRATIONS[0].up)
|
||||
// Änderungen in db.js MIGRATIONS müssen hier synchron gehalten werden.
|
||||
const MIGRATIONS_SQL = {
|
||||
1: `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
avatar_color TEXT NOT NULL DEFAULT '#007AFF',
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK(role IN ('admin', 'member')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
priority TEXT NOT NULL DEFAULT 'medium'
|
||||
CHECK(priority IN ('low', 'medium', 'high', 'urgent')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK(status IN ('open', 'in_progress', 'done')),
|
||||
due_date TEXT,
|
||||
due_time TEXT,
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS shopping_lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL
|
||||
CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
|
||||
title TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS shopping_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
is_checked INTEGER NOT NULL DEFAULT 0,
|
||||
added_from_meal INTEGER REFERENCES meals(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS meal_ingredients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
meal_id INTEGER NOT NULL REFERENCES meals(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT,
|
||||
on_shopping_list INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_datetime TEXT NOT NULL,
|
||||
end_datetime TEXT,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
color TEXT NOT NULL DEFAULT '#007AFF',
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
external_calendar_id TEXT,
|
||||
external_source TEXT NOT NULL DEFAULT 'local'
|
||||
CHECK(external_source IN ('local', 'google', 'apple')),
|
||||
recurrence_rule TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#FFEB3B',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
date TEXT NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
|
||||
AFTER UPDATE ON users FOR EACH ROW
|
||||
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_tasks_updated_at
|
||||
AFTER UPDATE ON tasks FOR EACH ROW
|
||||
BEGIN UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_shopping_lists_updated_at
|
||||
AFTER UPDATE ON shopping_lists FOR EACH ROW
|
||||
BEGIN UPDATE shopping_lists SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_shopping_items_updated_at
|
||||
AFTER UPDATE ON shopping_items FOR EACH ROW
|
||||
BEGIN UPDATE shopping_items SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_meals_updated_at
|
||||
AFTER UPDATE ON meals FOR EACH ROW
|
||||
BEGIN UPDATE meals SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_meal_ingredients_updated_at
|
||||
AFTER UPDATE ON meal_ingredients FOR EACH ROW
|
||||
BEGIN UPDATE meal_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_calendar_events_updated_at
|
||||
AFTER UPDATE ON calendar_events FOR EACH ROW
|
||||
BEGIN UPDATE calendar_events SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_notes_updated_at
|
||||
AFTER UPDATE ON notes FOR EACH ROW
|
||||
BEGIN UPDATE notes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at
|
||||
AFTER UPDATE ON contacts FOR EACH ROW
|
||||
BEGIN UPDATE contacts SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at
|
||||
AFTER UPDATE ON budget_entries FOR EACH ROW
|
||||
BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shopping_items_list ON shopping_items(list_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_date ON meals(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_start ON calendar_events(start_datetime);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_assigned ON calendar_events(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
|
||||
`,
|
||||
};
|
||||
|
||||
module.exports = { MIGRATIONS_SQL };
|
||||
+342
@@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Modul: Datenbank (Database)
|
||||
* Zweck: SQLite/SQLCipher Verbindung, Schema-Migration (versioniert) und Query-Helfer
|
||||
* Abhängigkeiten: better-sqlite3, dotenv
|
||||
*
|
||||
* SQLCipher-Hinweis:
|
||||
* Verschlüsselung funktioniert nur wenn better-sqlite3 gegen SQLCipher kompiliert wurde.
|
||||
* Im Docker-Container (Dockerfile: libsqlcipher-dev + npm rebuild) ist das gewährleistet.
|
||||
* Ohne DB_ENCRYPTION_KEY gesetzt läuft die App mit unverschlüsseltem SQLite (für Entwicklung).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
require('dotenv').config();
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, '..', 'oikos.db');
|
||||
const DB_KEY = process.env.DB_ENCRYPTION_KEY;
|
||||
|
||||
let db;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Initialisierung
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Datenbankverbindung öffnen, SQLCipher-Key setzen, Migrations ausführen.
|
||||
* Einmalig beim Serverstart aufrufen.
|
||||
* @returns {import('better-sqlite3').Database}
|
||||
*/
|
||||
function init() {
|
||||
db = new Database(DB_PATH);
|
||||
|
||||
if (DB_KEY) {
|
||||
// Nur wirksam wenn Binary gegen SQLCipher kompiliert ist (Docker)
|
||||
db.pragma(`key='${DB_KEY}'`);
|
||||
// Sicherstellen dass die Datenbank tatsächlich entschlüsselbar ist
|
||||
try {
|
||||
db.prepare('SELECT count(*) FROM sqlite_master').get();
|
||||
} catch {
|
||||
throw new Error('[DB] Falscher Verschlüsselungsschlüssel oder keine SQLCipher-Unterstützung.');
|
||||
}
|
||||
}
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.pragma('synchronous = NORMAL');
|
||||
db.pragma('temp_store = MEMORY');
|
||||
|
||||
migrate();
|
||||
|
||||
console.log(`[DB] Verbunden: ${DB_PATH} | Schema v${currentVersion()}`);
|
||||
return db;
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Migrations-Engine
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Alle Migrationen in aufsteigender Reihenfolge.
|
||||
* Neue Migrations am Ende anhängen — niemals bestehende ändern.
|
||||
*/
|
||||
const MIGRATIONS = [
|
||||
{
|
||||
version: 1,
|
||||
description: 'Initiales Schema',
|
||||
up: `
|
||||
-- Benutzer
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
avatar_color TEXT NOT NULL DEFAULT '#007AFF',
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK(role IN ('admin', 'member')),
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Aufgaben
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
priority TEXT NOT NULL DEFAULT 'medium'
|
||||
CHECK(priority IN ('low', 'medium', 'high', 'urgent')),
|
||||
status TEXT NOT NULL DEFAULT 'open'
|
||||
CHECK(status IN ('open', 'in_progress', 'done')),
|
||||
due_date TEXT,
|
||||
due_time TEXT,
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
parent_task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Einkaufslisten
|
||||
CREATE TABLE IF NOT EXISTS shopping_lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Essensplan (muss vor shopping_items stehen wegen FK-Referenz)
|
||||
CREATE TABLE IF NOT EXISTS meals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
meal_type TEXT NOT NULL
|
||||
CHECK(meal_type IN ('breakfast', 'lunch', 'dinner', 'snack')),
|
||||
title TEXT NOT NULL,
|
||||
notes TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Einkaufsartikel (nach meals, wegen added_from_meal FK)
|
||||
CREATE TABLE IF NOT EXISTS shopping_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
list_id INTEGER NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
is_checked INTEGER NOT NULL DEFAULT 0,
|
||||
added_from_meal INTEGER REFERENCES meals(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Mahlzeit-Zutaten
|
||||
CREATE TABLE IF NOT EXISTS meal_ingredients (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
meal_id INTEGER NOT NULL REFERENCES meals(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
quantity TEXT,
|
||||
on_shopping_list INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Kalender-Events
|
||||
CREATE TABLE IF NOT EXISTS calendar_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
start_datetime TEXT NOT NULL,
|
||||
end_datetime TEXT,
|
||||
all_day INTEGER NOT NULL DEFAULT 0,
|
||||
location TEXT,
|
||||
color TEXT NOT NULL DEFAULT '#007AFF',
|
||||
assigned_to INTEGER REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
external_calendar_id TEXT,
|
||||
external_source TEXT NOT NULL DEFAULT 'local'
|
||||
CHECK(external_source IN ('local', 'google', 'apple')),
|
||||
recurrence_rule TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Pinnwand / Notizen
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT,
|
||||
content TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#FFEB3B',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Kontakte
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
phone TEXT,
|
||||
email TEXT,
|
||||
address TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- Budget
|
||||
CREATE TABLE IF NOT EXISTS budget_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'Sonstiges',
|
||||
date TEXT NOT NULL,
|
||||
is_recurring INTEGER NOT NULL DEFAULT 0,
|
||||
recurrence_rule TEXT,
|
||||
created_by INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- updated_at Trigger (automatisch bei UPDATE setzen)
|
||||
-- --------------------------------------------------------
|
||||
CREATE TRIGGER IF NOT EXISTS trg_users_updated_at
|
||||
AFTER UPDATE ON users FOR EACH ROW
|
||||
BEGIN UPDATE users SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_tasks_updated_at
|
||||
AFTER UPDATE ON tasks FOR EACH ROW
|
||||
BEGIN UPDATE tasks SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_shopping_lists_updated_at
|
||||
AFTER UPDATE ON shopping_lists FOR EACH ROW
|
||||
BEGIN UPDATE shopping_lists SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_shopping_items_updated_at
|
||||
AFTER UPDATE ON shopping_items FOR EACH ROW
|
||||
BEGIN UPDATE shopping_items SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_meals_updated_at
|
||||
AFTER UPDATE ON meals FOR EACH ROW
|
||||
BEGIN UPDATE meals SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_meal_ingredients_updated_at
|
||||
AFTER UPDATE ON meal_ingredients FOR EACH ROW
|
||||
BEGIN UPDATE meal_ingredients SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_calendar_events_updated_at
|
||||
AFTER UPDATE ON calendar_events FOR EACH ROW
|
||||
BEGIN UPDATE calendar_events SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_notes_updated_at
|
||||
AFTER UPDATE ON notes FOR EACH ROW
|
||||
BEGIN UPDATE notes SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_at
|
||||
AFTER UPDATE ON contacts FOR EACH ROW
|
||||
BEGIN UPDATE contacts SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
CREATE TRIGGER IF NOT EXISTS trg_budget_entries_updated_at
|
||||
AFTER UPDATE ON budget_entries FOR EACH ROW
|
||||
BEGIN UPDATE budget_entries SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = OLD.id; END;
|
||||
|
||||
-- --------------------------------------------------------
|
||||
-- Indizes
|
||||
-- --------------------------------------------------------
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON tasks(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_parent ON tasks(parent_task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_shopping_items_list ON shopping_items(list_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_meals_date ON meals(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_start ON calendar_events(start_datetime);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_assigned ON calendar_events(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_notes_pinned ON notes(pinned);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_date ON budget_entries(date);
|
||||
CREATE INDEX IF NOT EXISTS idx_budget_created_by ON budget_entries(created_by);
|
||||
`,
|
||||
},
|
||||
// Zukünftige Migrations hier anhängen:
|
||||
// { version: 2, description: '...', up: '...' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Führt alle ausstehenden Migrations in einer Transaktion aus.
|
||||
*/
|
||||
function migrate() {
|
||||
// Migrations-Versions-Tabelle sicherstellen (außerhalb der Haupt-Transaktion)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const applied = new Set(
|
||||
db.prepare('SELECT version FROM schema_migrations').all().map((r) => r.version)
|
||||
);
|
||||
|
||||
const pending = MIGRATIONS.filter((m) => !applied.has(m.version));
|
||||
|
||||
if (pending.length === 0) return;
|
||||
|
||||
const runMigration = db.transaction((migration) => {
|
||||
db.exec(migration.up);
|
||||
db.prepare('INSERT INTO schema_migrations (version, description) VALUES (?, ?)')
|
||||
.run(migration.version, migration.description);
|
||||
console.log(`[DB] Migration ${migration.version} angewendet: ${migration.description}`);
|
||||
});
|
||||
|
||||
for (const migration of pending) {
|
||||
runMigration(migration);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuelle Schema-Version zurückgeben.
|
||||
* @returns {number}
|
||||
*/
|
||||
function currentVersion() {
|
||||
if (!db) return 0;
|
||||
try {
|
||||
const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get();
|
||||
return row?.v ?? 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Öffentliche API
|
||||
// --------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Datenbankinstanz zurückgeben.
|
||||
* @returns {import('better-sqlite3').Database}
|
||||
*/
|
||||
function get() {
|
||||
if (!db) throw new Error('[DB] Nicht initialisiert — init() zuerst aufrufen.');
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaktion-Helfer: Funktion wird atomar ausgeführt.
|
||||
* Bei Fehler wird automatisch rollback ausgeführt.
|
||||
* @param {Function} fn
|
||||
* @returns {any}
|
||||
*/
|
||||
function transaction(fn) {
|
||||
return get().transaction(fn)();
|
||||
}
|
||||
|
||||
module.exports = { init, get, transaction, currentVersion };
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Modul: Server Entry Point
|
||||
* Zweck: Express-App initialisieren, Middleware einbinden, Routen registrieren
|
||||
* Abhängigkeiten: express, helmet, dotenv, server/db.js, server/auth.js, server/routes/*
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
require('dotenv').config();
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const path = require('path');
|
||||
const db = require('./db');
|
||||
const { router: authRouter, sessionMiddleware, requireAuth } = require('./auth');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Datenbank initialisieren
|
||||
// --------------------------------------------------------
|
||||
db.init();
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Security-Middleware
|
||||
// --------------------------------------------------------
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
// Alpine.js CDN
|
||||
'https://cdn.jsdelivr.net',
|
||||
// Lucide Icons CDN
|
||||
'https://unpkg.com',
|
||||
],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", 'data:', 'https://openweathermap.org'],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'none'"],
|
||||
frameSrc: ["'none'"],
|
||||
},
|
||||
},
|
||||
hsts: {
|
||||
maxAge: 31536000,
|
||||
includeSubDomains: true,
|
||||
preload: true,
|
||||
},
|
||||
}));
|
||||
|
||||
// Trust Proxy für korrekte IP hinter Nginx
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Request-Parsing
|
||||
// --------------------------------------------------------
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Sessions
|
||||
// --------------------------------------------------------
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Statische Dateien (Frontend)
|
||||
// --------------------------------------------------------
|
||||
app.use(express.static(path.join(__dirname, '..', 'public'), {
|
||||
maxAge: process.env.NODE_ENV === 'production' ? '7d' : 0,
|
||||
etag: true,
|
||||
}));
|
||||
|
||||
// --------------------------------------------------------
|
||||
// API-Routen
|
||||
// --------------------------------------------------------
|
||||
app.use('/api/v1/auth', authRouter);
|
||||
|
||||
// Alle weiteren API-Routen erfordern Authentifizierung
|
||||
app.use('/api/v1', requireAuth);
|
||||
app.use('/api/v1/tasks', require('./routes/tasks'));
|
||||
app.use('/api/v1/shopping', require('./routes/shopping'));
|
||||
app.use('/api/v1/meals', require('./routes/meals'));
|
||||
app.use('/api/v1/calendar', require('./routes/calendar'));
|
||||
app.use('/api/v1/notes', require('./routes/notes'));
|
||||
app.use('/api/v1/contacts', require('./routes/contacts'));
|
||||
app.use('/api/v1/budget', require('./routes/budget'));
|
||||
app.use('/api/v1/weather', require('./routes/weather'));
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Health-Check (für Docker)
|
||||
// --------------------------------------------------------
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// SPA Fallback: Alle nicht-API-Routen → index.html
|
||||
// --------------------------------------------------------
|
||||
app.get('*', (req, res) => {
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(404).json({ error: 'Nicht gefunden.', code: 404 });
|
||||
}
|
||||
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Globaler Error-Handler
|
||||
// --------------------------------------------------------
|
||||
app.use((err, req, res, _next) => {
|
||||
console.error('[Server] Unbehandelter Fehler:', err);
|
||||
res.status(500).json({ error: 'Interner Serverfehler.', code: 500 });
|
||||
});
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Server starten
|
||||
// --------------------------------------------------------
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[Oikos] Server läuft auf Port ${PORT}`);
|
||||
console.log(`[Oikos] Umgebung: ${process.env.NODE_ENV || 'development'}`);
|
||||
});
|
||||
|
||||
module.exports = app;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Budget-Tracker (Budget)
|
||||
* Zweck: REST-API-Routen für Einnahmen und Ausgaben
|
||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Platzhalter — wird in Phase 3 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: [] }));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Kalender (Calendar)
|
||||
* Zweck: REST-API-Routen für Kalendereinträge und externe Kalender-Sync
|
||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Platzhalter — wird in Phase 3 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: [] }));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Kontakte (Contacts)
|
||||
* Zweck: REST-API-Routen für wichtige Familienkontakte
|
||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Platzhalter — wird in Phase 3 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: [] }));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Essensplan (Meals)
|
||||
* Zweck: REST-API-Routen für Mahlzeiten und Zutaten
|
||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Platzhalter — wird in Phase 2 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: [] }));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Pinnwand / Notizen (Notes)
|
||||
* Zweck: REST-API-Routen für Notizen
|
||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Platzhalter — wird in Phase 3 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: [] }));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Einkaufslisten (Shopping)
|
||||
* Zweck: REST-API-Routen für Einkaufslisten und -artikel
|
||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Platzhalter — wird in Phase 2 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: [] }));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Aufgaben (Tasks)
|
||||
* Zweck: REST-API-Routen für Aufgaben und Teilaufgaben
|
||||
* Abhängigkeiten: express, server/db.js, server/auth.js
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Platzhalter — wird in Phase 2 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: [] }));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Wetter-Proxy (Weather)
|
||||
* Zweck: Serverseitiger Proxy für OpenWeatherMap API (API-Key nie im Frontend)
|
||||
* Abhängigkeiten: express, node-fetch, dotenv
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Platzhalter — wird in Phase 4 implementiert
|
||||
router.get('/', (req, res) => res.json({ data: null }));
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Modul: Apple Calendar Sync (CalDAV)
|
||||
* Zweck: Bidirektionaler Sync mit iCloud Calendar via CalDAV-Protokoll
|
||||
* Abhängigkeiten: tsdav, server/db.js
|
||||
*/
|
||||
|
||||
// Platzhalter — wird in Phase 3 implementiert
|
||||
|
||||
module.exports = {
|
||||
sync: async () => null,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Modul: Google Calendar Sync
|
||||
* Zweck: OAuth 2.0 + bidirektionaler Sync mit Google Calendar API v3
|
||||
* Abhängigkeiten: googleapis, server/db.js
|
||||
*/
|
||||
|
||||
// Platzhalter — wird in Phase 3 implementiert
|
||||
|
||||
module.exports = {
|
||||
getAuthUrl: () => null,
|
||||
handleCallback: async () => null,
|
||||
sync: async () => null,
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Modul: Wiederholungsregeln (Recurrence)
|
||||
* Zweck: RRULE-Parser und -Generator für wiederkehrende Aufgaben und Termine
|
||||
* Abhängigkeiten: keine externen (eigene Implementierung für FREQ=DAILY/WEEKLY/MONTHLY)
|
||||
*/
|
||||
|
||||
// Platzhalter — wird in Phase 4 implementiert
|
||||
|
||||
module.exports = {
|
||||
nextOccurrence: () => null,
|
||||
expandRule: () => [],
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
Reference in New Issue
Block a user