Commits vergleichen
16 Commits
8da6ad83f0
...
main
| Autor | SHA1 | Datum | |
|---|---|---|---|
| 99a6b7437b | |||
| 671aaadc26 | |||
| 5b1f8b1cfe | |||
| ef153789cc | |||
| 7d67557be4 | |||
| 623bbdf5dd | |||
| c21be47428 | |||
| 395598c2b0 | |||
| 9bf298c26b | |||
| 15627cce99 | |||
| c8707d6cf4 | |||
| 87c391d2e6 | |||
| ad7432c833 | |||
| 50da44aabc | |||
| dad07c7879 | |||
| 627beb1353 |
@ -23,7 +23,46 @@
|
||||
"Bash(timeout /t 5 /nobreak)",
|
||||
"Bash(start chrome:*)",
|
||||
"WebSearch",
|
||||
"Bash(wc:*)"
|
||||
"Bash(wc:*)",
|
||||
"Bash(sqlite3:*)",
|
||||
"Bash(ls:*)",
|
||||
"Bash(docker restart:*)",
|
||||
"Bash(grep:*)",
|
||||
"Bash(docker build:*)",
|
||||
"Bash(docker stop:*)",
|
||||
"Bash(docker rm:*)",
|
||||
"Bash(docker run:*)",
|
||||
"Bash(find:*)",
|
||||
"Bash(rg:*)",
|
||||
"Bash(cp:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(docker start:*)",
|
||||
"Bash(apt list:*)",
|
||||
"Bash(sudo apt:*)",
|
||||
"Bash(sudo apt install:*)",
|
||||
"Bash(echo:*)",
|
||||
"Bash(docker inspect:*)",
|
||||
"Bash(touch:*)",
|
||||
"Bash(docker port:*)",
|
||||
"Bash(docker-compose down:*)",
|
||||
"Bash(__NEW_LINE__ cp /app/data/taskmate.db.backup-20260103-201322 /app/data/taskmate.db)",
|
||||
"Bash(docker system prune:*)",
|
||||
"Bash(docker cp:*)",
|
||||
"Bash(mv:*)",
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(chmod:*)",
|
||||
"Bash(./update-css.sh)",
|
||||
"Bash(./check-resize.sh)",
|
||||
"Bash(grep -n \"orange\\|#f97316\\|#ea580c\" /home/claude-dev/TaskMate/frontend/css/reminders.css)",
|
||||
"Bash(grep -A10 -B5 \"reminder-modal-title\\|modal-title\\|modal-header\" /home/claude-dev/TaskMate/frontend/css/reminders.css)",
|
||||
"Bash(grep -A10 -B5 \"modal-header\\|modal-title\" /home/claude-dev/TaskMate/frontend/css/modal.css)",
|
||||
"Bash(grep -A10 -B5 \"calendar-reminder\\|reminder.*calendar\" /home/claude-dev/TaskMate/frontend/css/reminders.css)",
|
||||
"Bash(grep -A15 -B5 \"calendar.*task\\|task.*calendar\" /home/claude-dev/TaskMate/frontend/js/calendar.js)",
|
||||
"WebFetch(domain:admin-panel-undso.aegis-sight.de)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(for:*)",
|
||||
"Bash(do )",
|
||||
"Bash(done)"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,16 +1,23 @@
|
||||
# =============================================================================
|
||||
# TASKMATE - Umgebungsvariablen
|
||||
# TASKMATE - Umgebungsvariablen (Beispiel)
|
||||
# =============================================================================
|
||||
# Kopieren Sie diese Datei nach .env und passen Sie die Werte an:
|
||||
# cp .env.example .env
|
||||
#
|
||||
# WICHTIG: Generieren Sie sichere Passwörter mit:
|
||||
# openssl rand -base64 32
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SERVER
|
||||
# -----------------------------------------------------------------------------
|
||||
PORT=3000
|
||||
PORT=3001
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SICHERHEIT
|
||||
# -----------------------------------------------------------------------------
|
||||
JWT_SECRET=c0c02ffaa6209fac125d90c3c69025abb0bc08b8d8d333b71ccf1c6e875a004e
|
||||
# JWT Secret - MUSS geändert werden! Generieren mit: openssl rand -hex 32
|
||||
JWT_SECRET=HIER_SICHEREN_WERT_GENERIEREN
|
||||
|
||||
# Session-Timeout in Minuten (automatischer Logout bei Inaktivität)
|
||||
SESSION_TIMEOUT=30
|
||||
@ -36,18 +43,19 @@ BACKUP_INTERVAL_HOURS=24
|
||||
# -----------------------------------------------------------------------------
|
||||
# BENUTZER (werden beim ersten Start angelegt)
|
||||
# -----------------------------------------------------------------------------
|
||||
USER1_USERNAME=HG
|
||||
USER1_PASSWORD=Hzfne313!fdEF34
|
||||
USER1_DISPLAYNAME=User 1
|
||||
USER1_USERNAME=user1
|
||||
USER1_PASSWORD=SICHERES_PASSWORT_HIER
|
||||
USER1_DISPLAYNAME=Benutzer 1
|
||||
USER1_COLOR=#00D4FF
|
||||
|
||||
USER2_USERNAME=MH
|
||||
USER2_USERNAME=user2
|
||||
USER2_PASSWORD=SICHERES_PASSWORT_HIER
|
||||
USER2_DISPLAYNAME=User 2
|
||||
USER2_DISPLAYNAME=Benutzer 2
|
||||
USER2_COLOR=#FF9500
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# GITEA-INTEGRATION
|
||||
# GITEA-INTEGRATION (optional)
|
||||
# -----------------------------------------------------------------------------
|
||||
GITEA_URL=https://gitea-undso.aegis-sight.de
|
||||
GITEA_TOKEN=8d76ec66b3e1f02e9b1e4848d8b15e0cdffe48df
|
||||
GITEA_TOKEN=GITEA_ACCESS_TOKEN_HIER
|
||||
GITEA_ORG=AegisSight
|
||||
30
.gitignore
vendored
Normale Datei
30
.gitignore
vendored
Normale Datei
@ -0,0 +1,30 @@
|
||||
# Umgebungsvariablen (enthält Secrets)
|
||||
.env
|
||||
|
||||
# Datenbank und Daten
|
||||
data/
|
||||
*.sqlite
|
||||
*.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
|
||||
# Uploads
|
||||
uploads/
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
|
||||
# OS-spezifisch
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
@ -15,11 +15,14 @@ INHALTSVERZEICHNIS
|
||||
3. Aufgaben verwalten
|
||||
4. Projekte und Spalten
|
||||
5. Kalenderansicht
|
||||
6. Genehmigung (Vorschläge)
|
||||
7. Archiv-Funktion
|
||||
8. Einstellungen
|
||||
9. Benutzerverwaltung (Admin)
|
||||
10. Tipps und Tricks
|
||||
6. Listenansicht
|
||||
7. Genehmigung (Vorschläge)
|
||||
8. Gitea-Integration
|
||||
9. Benachrichtigungen (Inbox)
|
||||
10. Archiv-Funktion
|
||||
11. Einstellungen
|
||||
12. Benutzerverwaltung (Admin)
|
||||
13. Tipps und Tricks
|
||||
|
||||
================================================================================
|
||||
1. ERSTE SCHRITTE
|
||||
@ -49,7 +52,7 @@ ADMINISTRATOREN
|
||||
|
||||
Standard-Admin-Zugangsdaten:
|
||||
- Benutzername: admin
|
||||
- Passwort: !1Data123
|
||||
- Passwort: [Vom Administrator gesetzt]
|
||||
|
||||
Nach der Anmeldung als regulärer Benutzer sehen Sie das Kanban-Board.
|
||||
|
||||
@ -58,9 +61,9 @@ DIE OBERFLAECHE
|
||||
---------------
|
||||
Die Oberflaeche besteht aus:
|
||||
|
||||
+-------------------------------------------------------------------------+
|
||||
| Logo | Projekt-Auswahl | Board | Kalender | Genehmigung | Suche | User |
|
||||
+-------------------------------------------------------------------------+
|
||||
+--------------------------------------------------------------------------------------+
|
||||
| Logo | Projekt-Auswahl | Board | Liste | Kalender | Genehmigung | Gitea | Suche | User |
|
||||
+--------------------------------------------------------------------------------------+
|
||||
| Filterleiste: Benutzer | Prioritaet | Labels | Faelligkeit |
|
||||
+------------------------------------------------------------------+
|
||||
| Statistik: Offen | In Arbeit | Erledigt | Ueberfaellig |
|
||||
@ -296,7 +299,45 @@ FILTER
|
||||
|
||||
|
||||
================================================================================
|
||||
6. GENEHMIGUNG (VORSCHLÄGE)
|
||||
6. LISTENANSICHT
|
||||
================================================================================
|
||||
|
||||
Die Listenansicht bietet eine tabellarische Übersicht aller Aufgaben.
|
||||
|
||||
ANSICHT WECHSELN
|
||||
----------------
|
||||
Klicken Sie auf "Liste" in der Navigationsleiste.
|
||||
|
||||
ANSICHTSMODI
|
||||
------------
|
||||
- Gruppierte Ansicht: Aufgaben nach Status gruppiert (einklappbar)
|
||||
- Flache Liste: Alle Aufgaben in einer Tabelle
|
||||
|
||||
SPALTEN
|
||||
-------
|
||||
- Aufgabe: Titel mit Farbpunkt der Spalte
|
||||
- Status: Aktueller Status (per Dropdown änderbar)
|
||||
- Priorität: Hoch/Mittel/Niedrig mit Sternen (per Dropdown änderbar)
|
||||
- Fälligkeitsdatum: Datum (per Datepicker änderbar)
|
||||
- Zugewiesen: Benutzer-Avatar (per Dropdown änderbar)
|
||||
|
||||
INLINE-BEARBEITUNG
|
||||
------------------
|
||||
Alle Felder können direkt in der Liste bearbeitet werden:
|
||||
- Status: Dropdown öffnen und ändern
|
||||
- Priorität: Dropdown öffnen und ändern
|
||||
- Datum: Datepicker öffnen und ändern
|
||||
- Zuweisung: Dropdown öffnen und ändern
|
||||
- Titel: Doppelklick zum Bearbeiten
|
||||
|
||||
SORTIERUNG
|
||||
----------
|
||||
Klicken Sie auf eine Spaltenüberschrift, um nach dieser Spalte zu sortieren.
|
||||
Erneutes Klicken kehrt die Sortierrichtung um.
|
||||
|
||||
|
||||
================================================================================
|
||||
7. GENEHMIGUNG (VORSCHLÄGE)
|
||||
================================================================================
|
||||
|
||||
Der Genehmigung-Bereich ermöglicht es Teammitgliedern, Vorschläge einzureichen,
|
||||
@ -343,7 +384,128 @@ VORSCHLAG LÖSCHEN
|
||||
|
||||
|
||||
================================================================================
|
||||
7. ARCHIV-FUNKTION
|
||||
8. GITEA-INTEGRATION
|
||||
================================================================================
|
||||
|
||||
Die Gitea-Integration ermöglicht die Verknüpfung von TaskMate-Projekten mit
|
||||
Git-Repositories auf einem Gitea-Server.
|
||||
|
||||
VORAUSSETZUNGEN
|
||||
---------------
|
||||
- Gitea-Server muss konfiguriert sein (GITEA_URL, GITEA_TOKEN in .env)
|
||||
- Docker-Container muss Zugriff auf lokale Laufwerke haben
|
||||
|
||||
GITEA-TAB ÖFFNEN
|
||||
----------------
|
||||
Klicken Sie auf "Gitea" in der Navigationsleiste.
|
||||
|
||||
REPOSITORY VERKNÜPFEN
|
||||
---------------------
|
||||
1. Verbindungsstatus prüfen (grün = verbunden)
|
||||
2. Repository aus Dropdown wählen ODER "Neues Repository erstellen"
|
||||
3. Lokalen Pfad eingeben (z.B. C:\Projekte\MeinProjekt)
|
||||
4. Standard-Branch festlegen (z.B. "main")
|
||||
5. "Speichern" klicken
|
||||
|
||||
NEUES REPOSITORY ERSTELLEN
|
||||
--------------------------
|
||||
1. "Neues Repository erstellen" klicken
|
||||
2. Name eingeben (wird automatisch formatiert)
|
||||
3. Optional: Beschreibung hinzufügen
|
||||
4. Sichtbarkeit wählen (Privat/Öffentlich)
|
||||
5. "Erstellen" klicken
|
||||
|
||||
GIT-STATUS
|
||||
----------
|
||||
Nach der Verknüpfung zeigt das Status-Panel:
|
||||
- Aktueller Branch (per Dropdown wechselbar)
|
||||
- Status: Clean (keine Änderungen) oder Dirty (Änderungen vorhanden)
|
||||
- Anzahl der geänderten Dateien
|
||||
|
||||
GIT-OPERATIONEN
|
||||
---------------
|
||||
- Fetch: Änderungen vom Server abrufen (ohne lokale Dateien zu ändern)
|
||||
- Pull: Änderungen vom Server herunterladen und einbinden
|
||||
- Push: Lokale Änderungen auf den Server hochladen
|
||||
- Commit: Änderungen mit Nachricht speichern
|
||||
|
||||
PUSH MIT BRANCH-AUSWAHL
|
||||
-----------------------
|
||||
Beim Push können Sie den Ziel-Branch wählen:
|
||||
- "Gleicher Name wie lokal": Push auf gleichnamigen Remote-Branch
|
||||
- "main", "master", "develop": Push auf spezifischen Branch
|
||||
- Force-Push: Überschreibt Remote-Historie (bei Konflikten)
|
||||
|
||||
BRANCH UMBENENNEN
|
||||
-----------------
|
||||
1. Auf das Stift-Symbol neben dem Branch-Dropdown klicken
|
||||
2. Neuen Branch-Namen eingeben
|
||||
3. "Umbenennen" klicken
|
||||
|
||||
ÄNDERUNGEN ANZEIGEN
|
||||
-------------------
|
||||
Die Änderungsliste zeigt alle modifizierten Dateien:
|
||||
- M = Modified (geändert)
|
||||
- A = Added (neu hinzugefügt)
|
||||
- D = Deleted (gelöscht)
|
||||
- ? = Untracked (nicht versioniert)
|
||||
|
||||
COMMIT-HISTORIE
|
||||
---------------
|
||||
Die letzten Commits werden mit Hash, Nachricht, Autor und Datum angezeigt.
|
||||
- X-Button: Commit aus der Anzeige ausblenden
|
||||
- "Alle ausblenden": Alle Commits aus der Anzeige entfernen
|
||||
|
||||
KONFIGURATION ENTFERNEN
|
||||
-----------------------
|
||||
1. Auf das Papierkorb-Symbol im Repository-Header klicken
|
||||
2. Bestätigen
|
||||
Die Verknüpfung wird entfernt, das Repository bleibt erhalten.
|
||||
|
||||
|
||||
================================================================================
|
||||
9. BENACHRICHTIGUNGEN (INBOX)
|
||||
================================================================================
|
||||
|
||||
Das Benachrichtigungssystem informiert Sie über wichtige Ereignisse in Echtzeit.
|
||||
|
||||
GLOCKEN-SYMBOL
|
||||
--------------
|
||||
- Position: Oben rechts in der Kopfzeile, neben dem Benutzer-Avatar
|
||||
- Grau: Keine ungelesenen Nachrichten
|
||||
- Farbig mit Badge: Anzahl ungelesener Nachrichten
|
||||
|
||||
INBOX ÖFFNEN
|
||||
------------
|
||||
Klicken Sie auf das Glocken-Symbol, um das Dropdown-Menü zu öffnen.
|
||||
|
||||
BENACHRICHTIGUNGSTYPEN
|
||||
----------------------
|
||||
- Aufgabe zugewiesen: Eine Aufgabe wurde Ihnen zugewiesen
|
||||
- Zuweisung entfernt: Sie wurden von einer Aufgabe entfernt
|
||||
- Bald fällig: Eine Ihrer Aufgaben ist morgen fällig
|
||||
- Aufgabe erledigt: Eine Aufgabe wurde abgeschlossen
|
||||
- Fälligkeit geändert: Das Fälligkeitsdatum wurde geändert
|
||||
- Priorität erhöht: Priorität wurde auf "Hoch" gesetzt
|
||||
- Neuer Kommentar: Kommentar zu Ihrer Aufgabe
|
||||
- @Erwähnung: Sie wurden in einem Kommentar erwähnt
|
||||
- Genehmigung ausstehend: Neue Genehmigung wartet (persistent)
|
||||
- Genehmigung erteilt: Vorschlag wurde genehmigt
|
||||
|
||||
PERSISTENTE BENACHRICHTIGUNGEN
|
||||
------------------------------
|
||||
Genehmigungsanfragen sind "persistent" - sie können nicht gelöscht werden
|
||||
und bleiben bis zur Genehmigung/Ablehnung bestehen.
|
||||
Sie sind mit einem gelben Rand markiert.
|
||||
|
||||
AKTIONEN
|
||||
--------
|
||||
- Klick auf Nachricht: Navigiert zur Aufgabe oder Genehmigung
|
||||
- "Alle als gelesen markieren": Markiert alle Nachrichten als gelesen
|
||||
|
||||
|
||||
================================================================================
|
||||
10. ARCHIV-FUNKTION
|
||||
================================================================================
|
||||
|
||||
Das Archiv speichert Aufgaben, die Sie nicht mehr aktiv benoetigen,
|
||||
@ -367,7 +529,7 @@ AUFGABE ARCHIVIEREN
|
||||
|
||||
|
||||
================================================================================
|
||||
8. EINSTELLUNGEN
|
||||
11. EINSTELLUNGEN
|
||||
================================================================================
|
||||
|
||||
Oeffnen Sie die Einstellungen ueber das Benutzer-Menu (oben rechts).
|
||||
@ -392,7 +554,7 @@ PASSWORT AENDERN
|
||||
|
||||
|
||||
================================================================================
|
||||
9. BENUTZERVERWALTUNG (ADMIN)
|
||||
12. BENUTZERVERWALTUNG (ADMIN)
|
||||
================================================================================
|
||||
|
||||
Die Benutzerverwaltung ist nur für Administratoren zugänglich und ermöglicht
|
||||
@ -458,7 +620,7 @@ HINWEIS: Der eigene Account kann nicht gelöscht werden.
|
||||
|
||||
|
||||
================================================================================
|
||||
10. TIPPS UND TRICKS
|
||||
13. TIPPS UND TRICKS
|
||||
================================================================================
|
||||
|
||||
SUCHE
|
||||
|
||||
2388
CHANGELOG.txt
2388
CHANGELOG.txt
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
818
CLAUDE.md
818
CLAUDE.md
@ -1,55 +1,777 @@
|
||||
# TaskMate - Projektanweisungen
|
||||
|
||||
## Allgemein
|
||||
- Sprache: Deutsch fuer Benutzer-Kommunikation
|
||||
- Aenderungen immer in CHANGELOG.txt dokumentieren nach bisher bekannten Schema in der Datei
|
||||
- Beim Start ANWENDUNGSBESCHREIBUNG.txt lesen
|
||||
- Cache-Version in frontend/sw.js erhoehen nach Aenderungen
|
||||
- Ich bin kein Mensch mit Fachwissen im Bereich Coding, daher musst du sämtliche Aufgaben in der Regel übernehmen.
|
||||
# TaskMate - Entwicklerdokumentation
|
||||
|
||||
## ⚠️ WICHTIGER HINWEIS FÜR KI-ASSISTENTEN
|
||||
Der Anwender hat **KEINE Programmierkenntnisse**. Das bedeutet:
|
||||
- **DU übernimmst ALLE technischen Aufgaben vollständig**
|
||||
- **Erkläre in einfachen Worten**, was du tust und warum
|
||||
- **Frage NIEMALS nach technischen Details** oder Code-Schnipseln
|
||||
- **Führe ALLE Schritte selbstständig aus**
|
||||
- Der Anwender kann nur bestätigen/ablehnen, nicht selbst coden
|
||||
|
||||
### Kommunikations-Regeln
|
||||
✅ **RICHTIG**: "Ich werde jetzt die Benutzeroberfläche anpassen, damit..."
|
||||
❌ **FALSCH**: "Kannst du mir den Code aus Zeile 42 zeigen?"
|
||||
|
||||
✅ **RICHTIG**: "Ich starte jetzt den Server neu. Das dauert etwa 30 Sekunden."
|
||||
❌ **FALSCH**: "Führe bitte folgenden Befehl aus: docker restart..."
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Wichtigste Befehle
|
||||
```bash
|
||||
# Docker Container neu starten (nach Backend-Änderungen)
|
||||
docker restart taskmate
|
||||
|
||||
# Container neu bauen (bei Dependency-Änderungen)
|
||||
docker build -t taskmate . && docker restart taskmate
|
||||
|
||||
# Logs anzeigen
|
||||
docker logs taskmate -f
|
||||
|
||||
# Health Check
|
||||
curl http://localhost:3000/api/health
|
||||
```
|
||||
|
||||
### Kritische Regeln - NIEMALS VERGESSEN! ⚠️
|
||||
1. **Cache-Version erhöhen** nach Frontend-Änderungen: `frontend/sw.js` → `CACHE_VERSION`
|
||||
2. **CHANGELOG.txt** bei JEDER Änderung aktualisieren
|
||||
3. **Keine `toISOString()`** für Datums-Operationen (UTC-Problem!)
|
||||
4. **Echtzeit-Updates** - User darf NIE F5 drücken müssen
|
||||
5. **Docker restart** nach Backend-Änderungen
|
||||
|
||||
### Datenschutz & Projektsicherheit 🔐
|
||||
**ABSOLUT KRITISCH**: Das Projekt "AegisSight" ist produktiv im Einsatz!
|
||||
|
||||
- **Projekt "AegisSight" NIEMALS löschen, ändern oder beeinträchtigen**
|
||||
- **Bestehende Benutzer NIEMALS zurücksetzen, löschen oder verändern**
|
||||
- **Produktivdaten sind TABU** - keine Testdaten in echte Projekte
|
||||
- **Keine Datenbank-Resets** ohne explizite Anweisung
|
||||
- **JEDE Änderung MUSS umkehrbar sein** - Live-System!
|
||||
- **Backup vor kritischen Änderungen** ist Pflicht
|
||||
|
||||
**⚠️ NUTZERDATEN-SCHUTZ - ABSOLUTES VERBOT**:
|
||||
- **KEINE Änderungen an Nutzerdaten oder Kennwörtern**
|
||||
- **Geschützte Benutzer (NICHT modifizieren)**:
|
||||
- admin
|
||||
- Hendrik (hendrik_gebhardt@gmx.de)
|
||||
- Monami (momohomma@googlemail.com)
|
||||
- **Diese Benutzer sind produktiv im Einsatz**
|
||||
- **Keine Passwort-Resets oder Änderungen an diesen Accounts**
|
||||
- **Bei Anmeldeproblemen: Nur Debugging, keine Datenänderung**
|
||||
|
||||
### Rollback-Strategie für Live-Betrieb
|
||||
Bei JEDER Änderung sicherstellen:
|
||||
|
||||
```bash
|
||||
# 1. Vor Änderungen - Backup erstellen
|
||||
cp data/taskmate.db data/taskmate.db.backup-$(date +%Y%m%d-%H%M%S)
|
||||
docker commit taskmate taskmate-backup-$(date +%Y%m%d-%H%M%S)
|
||||
|
||||
# 2. Bei Problemen - Rollback durchführen
|
||||
docker stop taskmate
|
||||
docker run -d --name taskmate-temp taskmate-backup-TIMESTAMP
|
||||
# Nach Test: docker rm -f taskmate && docker rename taskmate-temp taskmate
|
||||
|
||||
# 3. Code-Rollback via Git
|
||||
git stash # Aktuelle Änderungen sichern
|
||||
git checkout HEAD~1 # Zum vorherigen Commit
|
||||
docker build -t taskmate . && docker restart taskmate
|
||||
```
|
||||
|
||||
**Änderungs-Workflow für Live-System:**
|
||||
1. Backup von Datenbank UND Docker-Image
|
||||
2. Kleine, inkrementelle Änderungen
|
||||
3. Sofortiger Test nach jeder Änderung
|
||||
4. Rollback-Plan dokumentieren
|
||||
5. Bei kritischen Änderungen: Wartungsfenster planen
|
||||
|
||||
### Arbeitsweise mit nicht-technischem Anwender
|
||||
**Der Anwender versteht KEIN Coding!** Daher:
|
||||
|
||||
1. **Vollständige Übernahme**: Du führst ALLE technischen Schritte durch
|
||||
2. **Einfache Erklärungen**: "Ich passe jetzt X an, damit Y funktioniert"
|
||||
3. **Status-Updates**: "Änderung abgeschlossen, teste jetzt..."
|
||||
4. **Keine technischen Fragen**: Niemals nach Code, Logs oder Befehlen fragen
|
||||
5. **Proaktives Handeln**: Selbstständig debuggen und lösen
|
||||
|
||||
**Beispiel-Kommunikation:**
|
||||
- ✅ "Ich habe ein Problem gefunden und behebe es jetzt..."
|
||||
- ❌ "Welche Version von Node.js ist installiert?"
|
||||
- ✅ "Die Änderung ist fertig. Bitte die Seite neu laden und testen."
|
||||
- ❌ "Kannst du mal in die Console schauen?"
|
||||
|
||||
### Zugriff & Domains
|
||||
- **Frontend**: https://taskmate.aegis-sight.de
|
||||
- **Lokaler Port**: 3001 → Container Port 3000
|
||||
- **Gitea**: https://gitea-undso.aegis-sight.de/AegisSight/TaskMate
|
||||
- **Gitea-Token**: Siehe `.env` Datei (NIEMALS in Git einchecken!)
|
||||
|
||||
## 📁 Projektstruktur
|
||||
|
||||
### Wichtige Dateien - Hier starten!
|
||||
```
|
||||
frontend/js/app.js # Hauptanwendung & View-Controller
|
||||
backend/server.js # Express Server mit Socket.io
|
||||
backend/database.js # Datenbankschema (20+ Tabellen)
|
||||
frontend/js/store.js # State Management (Pub-Sub)
|
||||
frontend/js/api.js # API Client mit Auth/CSRF
|
||||
frontend/sw.js # Service Worker → CACHE_VERSION!
|
||||
CHANGELOG.txt # Änderungsprotokoll
|
||||
```
|
||||
|
||||
### Frontend-Module (22 Dateien)
|
||||
```
|
||||
# Core
|
||||
app.js # Hauptanwendung
|
||||
store.js # State Management
|
||||
api.js # Backend-Kommunikation
|
||||
auth.js # Login/Token-Verwaltung
|
||||
utils.js # Hilfsfunktionen
|
||||
|
||||
# Views
|
||||
board.js # Kanban-Board mit Drag&Drop
|
||||
calendar.js # Kalender (Monat/Woche)
|
||||
list.js # Listenansicht
|
||||
dashboard.js # Statistik-Dashboard
|
||||
proposals.js # Vorschlagssystem
|
||||
knowledge.js # Wissensdatenbank
|
||||
admin.js # Benutzerverwaltung
|
||||
|
||||
# Features
|
||||
task-modal.js # Aufgaben-Details
|
||||
notifications.js # Benachrichtigungen
|
||||
sync.js # Socket.io Echtzeit
|
||||
mobile.js # Mobile Features
|
||||
```
|
||||
|
||||
### Backend-Routes (22 Module)
|
||||
```
|
||||
/api/auth # Login/Logout
|
||||
/api/tasks # Aufgaben CRUD
|
||||
/api/projects # Projekte
|
||||
/api/columns # Kanban-Spalten
|
||||
/api/comments # Kommentare
|
||||
/api/files # Datei-Upload
|
||||
/api/proposals # Vorschläge
|
||||
/api/gitea # Git-Integration
|
||||
/api/knowledge # Wissensdatenbank
|
||||
```
|
||||
|
||||
## 🔧 Entwicklung
|
||||
|
||||
### Neue View/Ansicht hinzufügen
|
||||
```javascript
|
||||
// 1. Datei erstellen: frontend/js/myview.js
|
||||
export function initMyView() {
|
||||
// KRITISCH: Echtzeit-Updates registrieren!
|
||||
store.subscribe('tasks', updateView);
|
||||
window.addEventListener('app:refresh', updateView);
|
||||
|
||||
function updateView() {
|
||||
// UI aktualisieren
|
||||
}
|
||||
}
|
||||
|
||||
// 2. In index.html einbinden
|
||||
<script type="module" src="js/myview.js"></script>
|
||||
|
||||
// 3. Navigation erweitern in navigation.js
|
||||
```
|
||||
|
||||
### Neue API-Route erstellen
|
||||
```javascript
|
||||
// 1. Route-Datei: backend/routes/myroute.js
|
||||
const router = require('express').Router();
|
||||
const auth = require('../middleware/auth');
|
||||
|
||||
router.get('/', auth, (req, res) => {
|
||||
// Implementation
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
// 2. In server.js registrieren
|
||||
app.use('/api/myroute', require('./routes/myroute'));
|
||||
|
||||
// 3. Frontend API-Call in api.js
|
||||
async myRouteCall() {
|
||||
return this.request('/api/myroute');
|
||||
}
|
||||
```
|
||||
|
||||
### Datums-Formatierung (RICHTIG!)
|
||||
```javascript
|
||||
// ✅ RICHTIG - Lokale Zeit
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
|
||||
// ❌ FALSCH - UTC-Konvertierung
|
||||
const dateStr = date.toISOString().split('T')[0]; // NIEMALS!
|
||||
```
|
||||
|
||||
### Echtzeit-Updates implementieren
|
||||
```javascript
|
||||
// PFLICHT für ALLE Komponenten!
|
||||
// 1. Store-Subscriptions
|
||||
store.subscribe('tasks', () => renderTasks());
|
||||
store.subscribe('columns', () => updateColumns());
|
||||
store.subscribe('labels', () => refreshLabels());
|
||||
|
||||
// 2. Event-Listener
|
||||
window.addEventListener('app:refresh', () => {
|
||||
// Komplette UI aktualisieren
|
||||
});
|
||||
|
||||
window.addEventListener('modal:close', () => {
|
||||
// Nach Modal-Schließung
|
||||
});
|
||||
|
||||
// 3. Socket.io Events in sync.js
|
||||
socket.on('task:update', (data) => {
|
||||
store.updateTask(data);
|
||||
});
|
||||
```
|
||||
|
||||
## 💾 Datenbank
|
||||
|
||||
### Wichtige Tabellen
|
||||
```sql
|
||||
users # Benutzer mit Rollen
|
||||
projects # Projekte
|
||||
columns # Kanban-Spalten
|
||||
tasks # Aufgaben
|
||||
task_labels # M:N Labels
|
||||
task_assignees # M:N Zuweisungen
|
||||
comments # Kommentare
|
||||
attachments # Dateianhänge
|
||||
proposals # Vorschläge
|
||||
notifications # Benachrichtigungen
|
||||
knowledge_* # Wissensdatenbank
|
||||
```
|
||||
|
||||
### Schema ändern
|
||||
```javascript
|
||||
// 1. In backend/database.js anpassen
|
||||
CREATE TABLE new_table (
|
||||
id INTEGER PRIMARY KEY,
|
||||
...
|
||||
);
|
||||
|
||||
// 2. Datenbank neu initialisieren
|
||||
rm data/taskmate.db*
|
||||
docker restart taskmate
|
||||
|
||||
// 3. API & Frontend anpassen
|
||||
```
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
### Deployment-Checkliste
|
||||
```bash
|
||||
# 1. Vor Deployment
|
||||
- [ ] Keine console.log() im Code
|
||||
- [ ] Alle Features getestet
|
||||
- [ ] Keine Testdaten in DB
|
||||
|
||||
# 2. Deployment durchführen
|
||||
- [ ] Cache-Version erhöhen: frontend/sw.js
|
||||
- [ ] CHANGELOG.txt aktualisieren
|
||||
- [ ] Git commit & push
|
||||
- [ ] docker build -t taskmate .
|
||||
- [ ] docker restart taskmate
|
||||
|
||||
# 3. Nach Deployment
|
||||
- [ ] https://taskmate.aegis-sight.de testen
|
||||
- [ ] Browser-Cache leeren (Strg+F5)
|
||||
- [ ] Login, Aufgabe erstellen, etc. testen
|
||||
```
|
||||
|
||||
### Docker-Befehle
|
||||
```bash
|
||||
# Container Status
|
||||
docker ps -a | grep taskmate
|
||||
|
||||
# Container stoppen/starten
|
||||
docker stop taskmate
|
||||
docker start taskmate
|
||||
|
||||
# Container neu erstellen
|
||||
docker rm -f taskmate
|
||||
docker-compose up -d
|
||||
|
||||
# In Container Shell
|
||||
docker exec -it taskmate sh
|
||||
|
||||
# Logs live verfolgen
|
||||
docker logs taskmate -f --tail 100
|
||||
```
|
||||
|
||||
### KRITISCHES PROBLEM: Frontend-Änderungen werden nicht sichtbar
|
||||
|
||||
**Problem**: Frontend-Dateien (HTML, CSS, JS) werden beim Docker Build nach `/app/public/` kopiert und sind NICHT live gemountet. Änderungen in `/home/claude-dev/TaskMate/frontend/` werden daher nicht automatisch übernommen.
|
||||
|
||||
**Symptome**:
|
||||
- CSS/JS-Änderungen funktionieren nicht trotz Browser-Cache-Löschung
|
||||
- Service Worker Cache-Version Erhöhung hilft nicht
|
||||
- Änderungen werden sporadisch nach längerer Zeit sichtbar
|
||||
|
||||
**Ursache**:
|
||||
1. **Dockerfile kopiert Frontend-Dateien**: `COPY frontend/ ./public/`
|
||||
2. **Express.js cached statische Dateien** mit ETags und Last-Modified Headers
|
||||
3. **Mehrschichtiges Caching**: Service Worker + Browser + Express.js
|
||||
|
||||
**LÖSUNG A - Sofortige Änderungen (Development)**:
|
||||
```bash
|
||||
# Einzelne Datei kopieren
|
||||
docker cp frontend/css/style.css taskmate:/app/public/css/style.css
|
||||
|
||||
# Mehrere Dateien kopieren
|
||||
docker cp frontend/js/app.js taskmate:/app/public/js/app.js
|
||||
docker cp frontend/index.html taskmate:/app/public/index.html
|
||||
|
||||
# CSS + JS zusammen kopieren
|
||||
docker cp frontend/css/ taskmate:/app/public/css/
|
||||
docker cp frontend/js/ taskmate:/app/public/js/
|
||||
```
|
||||
|
||||
**LÖSUNG B - Vollständige Aktualisierung (Production)**:
|
||||
```bash
|
||||
# Docker Image neu bauen und Container ersetzen
|
||||
docker build -t taskmate . && docker restart taskmate
|
||||
```
|
||||
|
||||
**Express.js Caching deaktiviert**:
|
||||
```javascript
|
||||
// In server.js - statische Dateien ohne Caching
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
cacheControl: false,
|
||||
setHeaders: (res, path) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
}
|
||||
}));
|
||||
```
|
||||
|
||||
**Debugging-Workflow**:
|
||||
1. Prüfe Datei-Timestamps im Container: `docker exec taskmate ls -la /app/public/css/`
|
||||
2. Vergleiche mit lokalen Dateien: `ls -la frontend/css/`
|
||||
3. Bei Diskrepanz: Files mit `docker cp` aktualisieren
|
||||
4. Bei JavaScript-Problemen: Browser-Console auf Fehler prüfen
|
||||
|
||||
**Warum passiert das**:
|
||||
- Container-Pfad `/app/public/` = Static Files (nicht live)
|
||||
- Container-Pfad `/app/taskmate-source/` = Git-Operationen (live gemountet)
|
||||
- Frontend wird NUR beim Build-Time kopiert, nicht zur Laufzeit
|
||||
|
||||
## 🚨 KRITISCHE LEKTIONEN AUS PROBLEMEN
|
||||
|
||||
### ⚠️ Kontakte-Modul Implementation Probleme (07.01.2025)
|
||||
|
||||
**FEHLER 1: Backend API-Route nicht gefunden (404)**
|
||||
- **Problem**: GET /api/contacts gibt 404 - Endpoint nicht gefunden
|
||||
- **Ursache**: Backend-Dateien nicht im Docker-Container, Container nicht neu gestartet
|
||||
- **Lösung**: Backend-Dateien kopieren und Container neu starten
|
||||
- **Prävention**:
|
||||
```bash
|
||||
# Backend-Änderungen: Alle Dateien kopieren
|
||||
docker cp backend/routes/contacts.js taskmate:/app/routes/
|
||||
docker cp backend/middleware/validation.js taskmate:/app/middleware/
|
||||
docker cp backend/server.js taskmate:/app/server.js
|
||||
docker restart taskmate # IMMER nach Backend-Änderungen
|
||||
```
|
||||
|
||||
**FEHLER 2: Database Table existiert nicht (500 Internal Server Error)**
|
||||
- **Problem**: "no such table: contacts" - Tabelle wurde nicht erstellt
|
||||
- **Ursache**: database.js Änderungen nicht übernommen, bestehende DB erweitert sich nicht automatisch
|
||||
- **Lösung**: database.js kopieren + Tabelle manuell erstellen
|
||||
- **Pattern für neue Tabellen**:
|
||||
```bash
|
||||
docker cp backend/database.js taskmate:/app/database.js
|
||||
docker exec taskmate node -e "
|
||||
const Database = require('better-sqlite3');
|
||||
const db = new Database('/app/data/taskmate.db');
|
||||
db.exec('CREATE TABLE IF NOT EXISTS new_table (...);');
|
||||
console.log('Table created successfully');
|
||||
"
|
||||
```
|
||||
|
||||
**FEHLER 3: store.showMessage ist undefined**
|
||||
- **Problem**: `store.showMessage()` Funktion existiert nicht
|
||||
- **Ursache**: Falsche API für Toast-Nachrichten
|
||||
- **Lösung**: Verwende `window.dispatchEvent` mit `toast:show`
|
||||
- **Pattern für Toast-Messages**:
|
||||
```javascript
|
||||
// FALSCH: store.showMessage('Text', 'success')
|
||||
// RICHTIG:
|
||||
window.dispatchEvent(new CustomEvent('toast:show', {
|
||||
detail: { message: 'Text', type: 'success' }
|
||||
}));
|
||||
```
|
||||
|
||||
**FEHLER 4: Event-Handler nicht gebunden**
|
||||
- **Problem**: Button-Clicks funktionieren nicht trotz Event-Listener
|
||||
- **Ursache**: Timing-Problem - DOM noch nicht bereit, Modal-Overlay fehlt
|
||||
- **Lösung**: Korrekte Modal-Struktur + Overlay-Management
|
||||
- **Debugging-Pattern**:
|
||||
```javascript
|
||||
console.log('[Module] Element check:', this.buttonElement);
|
||||
if (this.buttonElement) {
|
||||
console.log('[Module] Binding event');
|
||||
this.buttonElement.addEventListener('click', () => {
|
||||
console.log('[Module] Button clicked!');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**FEHLER 5: Modal Design inkonsistent**
|
||||
- **Problem**: Custom Modal-Styles passen nicht zum App-Design
|
||||
- **Ursache**: Eigene CSS-Klassen statt Standard-Modal-Pattern
|
||||
- **Lösung**: Standard Modal-Struktur verwenden
|
||||
- **Standard Modal-Pattern**:
|
||||
```html
|
||||
<div class="modal modal-medium hidden">
|
||||
<div class="modal-header">
|
||||
<h2>Title</h2>
|
||||
<button class="modal-close" data-close-modal>×</button>
|
||||
</div>
|
||||
<div class="modal-body"><!-- Content --></div>
|
||||
<div class="modal-footer"><!-- Buttons --></div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### ⚠️ SVG Icon-Rendering Probleme (07.01.2025)
|
||||
|
||||
**FEHLER: SVG Icons werden nicht angezeigt**
|
||||
- **Problem**: SVG-Icons verschwinden oder zeigen nicht korrekt an
|
||||
- **Ursache**: `createElement()` unterstützt kein SVG-Namespace
|
||||
- **Lösung**: SVG mit `innerHTML` oder als String-Template einfügen
|
||||
- **Pattern**:
|
||||
```javascript
|
||||
// FALSCH - SVG wird nicht gerendert
|
||||
const icon = createElement('svg', { viewBox: '0 0 24 24' });
|
||||
|
||||
## Technologie
|
||||
- Frontend: Vanilla JavaScript (kein Framework)
|
||||
- Backend: Node.js mit Express
|
||||
- Datenbank: SQLite
|
||||
// RICHTIG - SVG als HTML-String
|
||||
element.innerHTML = `<svg viewBox="0 0 24 24">...</svg>`;
|
||||
```
|
||||
- **Prävention**: Bei dynamischen SVGs immer innerHTML oder DOMParser nutzen
|
||||
|
||||
### ⚠️ API Field Name Mismatches (07.01.2025)
|
||||
|
||||
**FEHLER: Frontend/Backend Feldnamen-Diskrepanz**
|
||||
- **Problem**: Daten werden mit 0 Bytes oder leer angezeigt
|
||||
- **Ursache**: Backend sendet camelCase, Frontend erwartet snake_case
|
||||
- **Beispiel**: `originalName` vs `original_name`, `sizeBytes` vs `size_bytes`
|
||||
- **Lösung**: Fallback-Pattern für beide Schreibweisen
|
||||
- **Pattern**:
|
||||
```javascript
|
||||
// Robuste Feldabfrage mit Fallback
|
||||
const fileName = data.originalName || data.original_name || '';
|
||||
const fileSize = data.sizeBytes || data.size_bytes || 0;
|
||||
```
|
||||
- **Prävention**: API-Dokumentation prüfen, einheitliche Naming-Convention
|
||||
|
||||
### ⚠️ File Upload Field Names (07.01.2025)
|
||||
|
||||
**FEHLER: Multer "Unexpected field" Error**
|
||||
- **Problem**: 500 Error bei File-Upload
|
||||
- **Ursache**: Frontend sendet 'files' (plural), Backend erwartet 'file' (singular)
|
||||
- **Lösung**: Backend-Konsistenz herstellen
|
||||
- **Pattern**:
|
||||
```javascript
|
||||
// Backend - Konsistent 'files' verwenden
|
||||
upload.single('files') // NICHT 'file'
|
||||
|
||||
## Konventionen
|
||||
- CSS-Variablen in frontend/css/variables.css
|
||||
- Deutsche Umlaute (ä, ö, ü) in Texten verwenden
|
||||
// Frontend - FormData immer mit 'files'
|
||||
formData.append('files', file);
|
||||
```
|
||||
- **Prävention**: Einheitliche Field-Names über alle Upload-Endpoints
|
||||
|
||||
## Datumsformatierung (WICHTIG)
|
||||
- NIEMALS `toISOString()` für Datumsvergleiche oder -anzeigen verwenden!
|
||||
- `toISOString()` konvertiert in UTC und verursacht Zeitzonenverschiebungen (z.B. 28.12. wird zu 27.12.)
|
||||
- Stattdessen lokale Formatierung verwenden:
|
||||
```javascript
|
||||
// Richtig: Lokale Formatierung
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const dateStr = `${year}-${month}-${day}`;
|
||||
### ⚠️ Erinnerung-Implementation Probleme (06.01.2026)
|
||||
|
||||
// Falsch: UTC-Konvertierung
|
||||
const dateStr = date.toISOString().split('T')[0]; // NICHT VERWENDEN!
|
||||
```
|
||||
**FEHLER 1: Syntax-Fehler in JavaScript blockierte Login**
|
||||
- **Problem**: Missing closing brace in calendar.js verhinderte Login komplett
|
||||
- **Ursache**: Unvollständige Code-Blöcke beim Multi-Edit
|
||||
- **Lösung**: IMMER Syntax-Check nach JavaScript-Änderungen
|
||||
- **Prävention**:
|
||||
```bash
|
||||
# Nach JS-Änderungen prüfen:
|
||||
node -c frontend/js/calendar.js
|
||||
docker logs taskmate --tail 20 # Auf Syntax-Fehler prüfen
|
||||
```
|
||||
|
||||
## Echtzeit-Aktualisierung (KRITISCH)
|
||||
- ALLE Nutzeranpassungen müssen SOFORT und ÜBERALL in der Anwendung sichtbar sein
|
||||
- Der Nutzer darf NIEMALS den Browser aktualisieren müssen (F5), um Änderungen zu sehen
|
||||
- Beispiele für Änderungen, die sofort überall wirken müssen:
|
||||
- Spaltenfarbe ändern → Board, Kalender, Wochenstreifen sofort aktualisieren
|
||||
- Aufgabe erstellen/bearbeiten/löschen → alle Ansichten sofort aktualisieren
|
||||
- Labels, Benutzer, Projekte ändern → überall sofort sichtbar
|
||||
- Technische Umsetzung:
|
||||
- `store.subscribe('tasks', callback)` - für Aufgabenänderungen
|
||||
- `store.subscribe('columns', callback)` - für Spaltenänderungen
|
||||
- `store.subscribe('labels', callback)` - für Label-Änderungen
|
||||
- `store.subscribe('users', callback)` - für Benutzeränderungen
|
||||
- `window.addEventListener('app:refresh', callback)` - für allgemeine Aktualisierungen
|
||||
- `window.addEventListener('modal:close', callback)` - nach Modal-Schließung
|
||||
- Bei JEDER neuen Komponente diese Event-Listener einbauen
|
||||
- Bei JEDER Datenänderung prüfen: Welche UI-Bereiche müssen aktualisiert werden?
|
||||
**FEHLER 2: "Verschwundene" Projekte durch 401-Fehler**
|
||||
- **Problem**: User dachte AegisSight-Projekt sei gelöscht
|
||||
- **Ursache**: Authentifizierungs-Token abgelaufen, API gibt 401 zurück
|
||||
- **Diagnose**: `docker logs taskmate` zeigt 401-Fehler
|
||||
- **Lösung**: Einfach neu anmelden, Daten sind intakt
|
||||
- **Prävention**: Bei "verschwundenen" Daten IMMER zuerst Auth prüfen
|
||||
|
||||
## Berechtigungen/Aktionen
|
||||
- Du sollst den Dockercontainer eigenständig - sofern erforderlich - neu starten/neu bauen, dass Änderungen wirksam werden
|
||||
- Nach Änderungen immer nach dem Neubau des Dockercontainers oder allgemein ohne Neustart des Dockercontainers, den Browser im Inkognito-Modus starten mit localhost:3000, um die Änderungen sichtbar zu machen.
|
||||
**FEHLER 3: Checkbox-Styling funktioniert nicht**
|
||||
- **Problem**: CSS-Selektoren greifen nicht, komplexe Pseudo-Element-Struktur
|
||||
- **Ursache**: Browser-CSS-Konflikte, CSS-Variable-Probleme
|
||||
- **Lösung**: Direktes Styling nativer Checkboxes mit `appearance: none`
|
||||
- **Lesson**: Bei CSS-Problemen: **Einfachster Ansatz zuerst**
|
||||
```css
|
||||
/* FALSCH: Komplexe Pseudo-Struktur */
|
||||
input:checked + span::after { content: '✓'; }
|
||||
|
||||
|
||||
/* RICHTIG: Direktes Styling */
|
||||
input[type="checkbox"] {
|
||||
appearance: none;
|
||||
background: #3B82F6 when :checked;
|
||||
}
|
||||
```
|
||||
|
||||
**FEHLER 4: Event-Handler Konflikte bei Modal-Updates**
|
||||
- **Problem**: Dropdown-Handler werden überschrieben
|
||||
- **Ursache**: Mehrfache Event-Binding ohne Cleanup
|
||||
- **Lösung**: Element-Kloning für saubere Event-Handler
|
||||
- **Pattern**:
|
||||
```javascript
|
||||
// Event-Handler cleanup durch Klonen
|
||||
const newElement = element.cloneNode(true);
|
||||
element.parentNode.replaceChild(newElement, element);
|
||||
// Dann neue Handler binden
|
||||
```
|
||||
|
||||
**FEHLER 5: Visuelle Darstellung unterbricht Funktionalität**
|
||||
- **Problem**: Erinnerungen unterbrachen Aufgaben-Balken
|
||||
- **Ursache**: Falsche Render-Reihenfolge (Erinnerungen vor Aufgaben)
|
||||
- **Lösung**: Aufgaben zuerst, dann Erinnerungen
|
||||
- **Lesson**: UI-Reihenfolge muss Funktionalität folgen, nicht umgekehrt
|
||||
|
||||
### ⚠️ Dropdown-Transparenz-Probleme (09.01.2026)
|
||||
|
||||
**FEHLER: Dropdown-Menüs haben unerwünschte Transparenz**
|
||||
- **Problem**: Dropdown-Menüs zeigen durchscheinenden Hintergrund, besonders bei dunklen Themes
|
||||
- **Symptome**:
|
||||
- Text schwer lesbar durch transparenten Hintergrund
|
||||
- Inhalte dahinter scheinen durch
|
||||
- Besonders auffällig bei Kontakte-Modul und anderen Dropdown-Menüs
|
||||
- **Ursache**: CSS-Variablen für Hintergründe verwenden `rgba()` mit Transparenz
|
||||
- **Lösung**: Explizite, nicht-transparente Hintergrundfarben für Dropdowns setzen
|
||||
- **Pattern**:
|
||||
```css
|
||||
/* FALSCH - Transparente Hintergründe */
|
||||
.dropdown {
|
||||
background: var(--bg-secondary); /* rgba mit 0.95 opacity */
|
||||
}
|
||||
|
||||
/* RICHTIG - Solide Hintergründe für Dropdowns */
|
||||
.dropdown {
|
||||
background: #ffffff; /* Hell-Theme */
|
||||
background: #1a1a1a; /* Dunkel-Theme */
|
||||
}
|
||||
```
|
||||
- **Prävention**:
|
||||
- Dropdown-Komponenten immer mit soliden Hintergründen
|
||||
- Keine CSS-Variablen mit Transparenz für interaktive Elemente
|
||||
- Bei Theme-Support: Explizite Farben ohne Alpha-Kanal
|
||||
|
||||
### 🔧 TROUBLESHOOTING-WORKFLOW
|
||||
|
||||
**Bei JavaScript-Fehlern:**
|
||||
1. `docker logs taskmate --tail 50` prüfen
|
||||
2. Browser-Console auf Syntax-Fehler prüfen
|
||||
3. Node.js Syntax-Check: `node -c datei.js`
|
||||
|
||||
**Bei "verschwundenen" Daten:**
|
||||
1. **NIEMALS** sofort Backup/Restore - erst debuggen!
|
||||
2. API-Logs prüfen auf 401/403 Fehler
|
||||
3. Auth-Status prüfen: `localStorage.getItem('token')`
|
||||
4. Datenbank direkt prüfen: `sqlite3 data/taskmate.db "SELECT COUNT(*) FROM projects"`
|
||||
|
||||
**Bei CSS-Problemen:**
|
||||
1. Simplest approach first - keine komplexen Selektoren
|
||||
2. `!important` nur als letzter Ausweg
|
||||
3. Browser-DevTools: Computed Styles prüfen
|
||||
4. Cache leeren: `CACHE_VERSION++` in sw.js
|
||||
|
||||
**Bei neuen Modulen mit globaler Suche:**
|
||||
1. Module in app.js setupSearch() registrieren:
|
||||
```javascript
|
||||
} else if (currentView === 'mymodule') {
|
||||
import('./mymodule.js').then(module => {
|
||||
if (module.myManager) {
|
||||
module.myManager.searchQuery = value;
|
||||
module.myManager.filterData();
|
||||
}
|
||||
});
|
||||
```
|
||||
2. Manager-Instanz exportieren: `export { myManager }`
|
||||
3. clearSearch() Funktion ebenfalls erweitern
|
||||
4. Lokale Suchfelder entfernen - nur Header-Suche nutzen
|
||||
|
||||
**Bei File-Upload Problemen:**
|
||||
1. Prüfe ob Entry/Task bereits gespeichert ist (ID vorhanden)
|
||||
2. Bei neuen Einträgen: Erst speichern, dann Upload
|
||||
3. Field-Name Konsistenz prüfen: 'files' (plural) überall
|
||||
4. `docker logs taskmate` für Multer-Errors checken
|
||||
|
||||
## 🐛 Troubleshooting
|
||||
|
||||
### Häufige Probleme
|
||||
|
||||
**401 Unauthorized**
|
||||
- Token abgelaufen → Neu einloggen
|
||||
- Prüfen: localStorage.getItem('token')
|
||||
|
||||
**CSRF Token ungültig**
|
||||
- Browser-Cache/Cookies löschen
|
||||
- Neu einloggen
|
||||
|
||||
**Änderungen nicht sichtbar**
|
||||
- Service Worker Cache → sw.js Version erhöhen!
|
||||
- Browser: Strg+F5
|
||||
- Prüfen: Echtzeit-Updates implementiert?
|
||||
|
||||
**Docker startet nicht**
|
||||
```bash
|
||||
docker logs taskmate
|
||||
docker ps -a | grep taskmate
|
||||
netstat -tulpn | grep 3001
|
||||
```
|
||||
|
||||
**Datenbank-Fehler**
|
||||
```bash
|
||||
# Backup erstellen
|
||||
cp data/taskmate.db data/taskmate.db.backup
|
||||
|
||||
# Integrität prüfen
|
||||
sqlite3 data/taskmate.db "PRAGMA integrity_check;"
|
||||
|
||||
# Schema anzeigen
|
||||
sqlite3 data/taskmate.db ".schema"
|
||||
```
|
||||
|
||||
### Debug-Tipps
|
||||
|
||||
**Frontend Debugging**
|
||||
```javascript
|
||||
// Store-Status prüfen
|
||||
console.log(store.getState());
|
||||
|
||||
// API-Calls tracken
|
||||
window.api.debug = true;
|
||||
|
||||
// Socket-Events loggen
|
||||
window.socket.on('*', console.log);
|
||||
```
|
||||
|
||||
**Backend Debugging**
|
||||
```javascript
|
||||
// In server.js
|
||||
app.use((req, res, next) => {
|
||||
console.log(`${req.method} ${req.path}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// SQL Queries loggen
|
||||
db.prepare(sql).run(); // Vorher console.log(sql)
|
||||
```
|
||||
|
||||
## 📋 Code-Patterns
|
||||
|
||||
### API Response Format
|
||||
```javascript
|
||||
// Erfolg
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
|
||||
// Fehler
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'Fehlermeldung'
|
||||
});
|
||||
```
|
||||
|
||||
### Store Update Pattern
|
||||
```javascript
|
||||
// Daten aktualisieren
|
||||
store.updateTasks(tasks);
|
||||
|
||||
// Wird automatisch ausgelöst:
|
||||
// - Alle task-Subscriber
|
||||
// - Socket.io Broadcast
|
||||
// - UI-Updates
|
||||
```
|
||||
|
||||
### Modal Pattern
|
||||
```javascript
|
||||
// Modal öffnen
|
||||
modal.show();
|
||||
|
||||
// WICHTIG: Bei Schließung
|
||||
modal.addEventListener('close', () => {
|
||||
window.dispatchEvent(new CustomEvent('modal:close'));
|
||||
// Triggert UI-Updates!
|
||||
});
|
||||
```
|
||||
|
||||
## 🔒 Sicherheit
|
||||
|
||||
### Authentifizierung
|
||||
- JWT Token mit 24h Gültigkeit
|
||||
- Refresh bei jeder Aktivität
|
||||
- Token in localStorage
|
||||
|
||||
### CSRF-Schutz
|
||||
- Token bei Login generiert
|
||||
- Bei jeder Mutation mitgesendet
|
||||
- Header: `X-CSRF-Token`
|
||||
|
||||
### Berechtigungen
|
||||
- Admin: Nur Benutzerverwaltung
|
||||
- User: Alles außer Admin-Bereich
|
||||
- Projekt-basierte Rechte
|
||||
|
||||
## 📝 Wichtige Konventionen
|
||||
|
||||
- **Sprache**: Deutsch für UI, Englisch für Code
|
||||
- **Umlaute**: ä, ö, ü verwenden (keine ae, oe, ue)
|
||||
- **CSS**: Variablen in `frontend/css/variables.css`
|
||||
- **Keine Emojis** in Code/UI (nur Doku)
|
||||
- **Auto-Save**: Änderungen werden automatisch gespeichert
|
||||
|
||||
## 🎯 Performance
|
||||
|
||||
### Frontend
|
||||
- Lazy Loading für Views
|
||||
- Debouncing bei Suche/Filter
|
||||
- Virtual Scrolling bei langen Listen
|
||||
- Service Worker Caching
|
||||
|
||||
### Backend
|
||||
- SQLite mit WAL Mode
|
||||
- Prepared Statements
|
||||
- Index auf häufig gefilterte Spalten
|
||||
- Pagination bei großen Datenmengen
|
||||
|
||||
## 🔄 Git Workflow
|
||||
|
||||
### Lokales Repository
|
||||
```bash
|
||||
# Status prüfen
|
||||
git status
|
||||
|
||||
# Commit erstellen
|
||||
git add .
|
||||
git commit -m "Beschreibung der Änderung"
|
||||
|
||||
# Zu Gitea pushen
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Gitea Integration
|
||||
- Automatischer Push bei Commits
|
||||
- Repository-Projekt Verknüpfung
|
||||
- Branch-Verwaltung in UI
|
||||
|
||||
---
|
||||
|
||||
**Hinweis**: Diese Dokumentation ist für die KI-gestützte Entwicklung optimiert. Bei Fragen die `ANWENDUNGSBESCHREIBUNG.txt` für Endnutzer-Dokumentation konsultieren.
|
||||
@ -22,7 +22,7 @@ RUN git config --system user.email "taskmate@local" && \
|
||||
COPY backend/package*.json ./
|
||||
|
||||
# Abhängigkeiten installieren
|
||||
RUN npm ci --only=production
|
||||
RUN npm install --only=production
|
||||
|
||||
# Build-Abhängigkeiten entfernen (kleineres Image)
|
||||
RUN apk del python3 make g++
|
||||
|
||||
179
GITEA_INTEGRATION_STATUS.md
Normale Datei
179
GITEA_INTEGRATION_STATUS.md
Normale Datei
@ -0,0 +1,179 @@
|
||||
# Gitea-Integration Status - TaskMate
|
||||
|
||||
**Datum:** 29.12.2024
|
||||
**Status:** Vollständig implementiert - Bugfix angewendet
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die Gitea-Integration für TaskMate ist vollständig implementiert. Der kritische Branch-Mismatch Bug wurde behoben - nach einem Push wird der Default-Branch in Gitea automatisch auf den gepushten Branch aktualisiert.
|
||||
|
||||
---
|
||||
|
||||
## Was wurde bereits implementiert
|
||||
|
||||
### Backend (vollständig)
|
||||
|
||||
1. **`backend/server.js`** - Routes registriert:
|
||||
```javascript
|
||||
const gitRoutes = require('./routes/git');
|
||||
const applicationsRoutes = require('./routes/applications');
|
||||
const giteaRoutes = require('./routes/gitea');
|
||||
|
||||
app.use('/api/git', authenticateToken, csrfProtection, gitRoutes);
|
||||
app.use('/api/applications', authenticateToken, csrfProtection, applicationsRoutes);
|
||||
app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
|
||||
```
|
||||
|
||||
2. **`backend/routes/gitea.js`** - NEU erstellt mit Endpoints:
|
||||
- `GET /test` - Verbindung testen
|
||||
- `GET /repositories` - Alle Repos auflisten
|
||||
- `POST /repositories` - Neues Repo erstellen
|
||||
- `GET /repositories/:owner/:repo` - Repo-Details
|
||||
- `GET /repositories/:owner/:repo/branches` - Branches
|
||||
- `GET /repositories/:owner/:repo/commits` - Commits
|
||||
|
||||
3. **`backend/routes/git.js`** - Erweitert mit:
|
||||
- `POST /set-remote` - Remote setzen
|
||||
- `POST /prepare-for-gitea` - Repository für Gitea vorbereiten
|
||||
|
||||
4. **`backend/services/gitService.js`** - Erweitert mit:
|
||||
- `setRemote()` - Remote-URL setzen
|
||||
- `prepareForGitea()` - Repository für Gitea vorbereiten (remote + initial commit)
|
||||
- `pushWithUpstream()` - Push mit automatischer Branch-Erkennung
|
||||
|
||||
5. **`backend/services/giteaService.js`** - Vorhanden mit:
|
||||
- `listRepositories()` - Organisation-Repositories auflisten
|
||||
- `getRepository()` - Einzelnes Repo abrufen
|
||||
- `createRepository()` - Neues Repo erstellen
|
||||
- `updateRepository()` - Repo-Einstellungen aktualisieren (inkl. default_branch)
|
||||
- `deleteRepository()` - Repo löschen
|
||||
- `testConnection()` - Verbindung testen
|
||||
- `getAuthenticatedCloneUrl()` - Clone-URL mit Token
|
||||
|
||||
6. **`Dockerfile`** - Git-Unterstützung hinzugefügt:
|
||||
```dockerfile
|
||||
RUN apk add --no-cache git
|
||||
RUN git config --system --add safe.directory '*'
|
||||
RUN git config --system user.email "taskmate@local" && \
|
||||
git config --system user.name "TaskMate" && \
|
||||
git config --system init.defaultBranch main
|
||||
```
|
||||
|
||||
### Frontend (vollständig)
|
||||
|
||||
1. **`frontend/js/api.js`** - Erweitert mit allen Gitea/Git API-Methoden
|
||||
|
||||
2. **`frontend/js/gitea.js`** - NEU erstellt:
|
||||
- Kompletter GiteaManager mit allen Funktionen
|
||||
- Konfigurationsansicht
|
||||
- Status-Anzeige
|
||||
- Git-Operationen (Fetch, Pull, Push, Commit)
|
||||
- Commit-Historie
|
||||
- Branch-Wechsel
|
||||
|
||||
3. **`frontend/css/gitea.css`** - NEU erstellt:
|
||||
- Alle Styles für Gitea-Tab
|
||||
|
||||
4. **`frontend/index.html`** - Erweitert:
|
||||
- Gitea-Tab in Navigation
|
||||
- Gitea-View Container
|
||||
- Commit-Modal
|
||||
- Create-Repo-Modal
|
||||
|
||||
5. **`frontend/js/app.js`** - Integriert:
|
||||
- GiteaManager Import
|
||||
- View-Switching für Gitea-Tab
|
||||
|
||||
6. **`frontend/sw.js`** - Cache-Version erhöht
|
||||
|
||||
---
|
||||
|
||||
## Der gelöste Bug: Branch-Mismatch
|
||||
|
||||
### Problem (GELÖST)
|
||||
- 146 Dateien wurden erfolgreich nach Gitea gepusht
|
||||
- Die Dateien landeten im Branch `master`
|
||||
- Gitea zeigte aber `main` als Standard-Branch an
|
||||
- Benutzer sahen nur die auto-generierte README statt ihrer Dateien
|
||||
|
||||
### Ursache
|
||||
1. Gitea erstellt Repositories mit `default_branch: 'main'` (Zeile 179 in giteaService.js)
|
||||
2. Der lokale Git-Branch heißt aber `master`
|
||||
3. Die Dateien wurden nach `master` gepusht
|
||||
4. Gitea zeigte `main` an (leer bis auf README)
|
||||
|
||||
### Implementierte Lösung
|
||||
|
||||
1. **`updateRepository()` Funktion** zu `backend/services/giteaService.js` hinzugefügt
|
||||
- Ändert den Default-Branch über Gitea PATCH API
|
||||
- Unterstützt auch Beschreibung und Private-Status
|
||||
|
||||
2. **`pushWithUpstream()` Funktion** in `backend/services/gitService.js` erweitert
|
||||
- Gibt jetzt den Branch-Namen im Ergebnis zurück
|
||||
|
||||
3. **`init-push` Endpoint** in `backend/routes/git.js` erweitert
|
||||
- Nach erfolgreichem Push wird automatisch der Default-Branch in Gitea aktualisiert
|
||||
- Owner/Repo wird aus der Repository-URL extrahiert
|
||||
|
||||
4. **Cache-Version** auf 119 erhöht in `frontend/sw.js`
|
||||
|
||||
---
|
||||
|
||||
## Gelöste Probleme (zur Referenz)
|
||||
|
||||
1. **"git: not found"** → Git in Dockerfile installiert
|
||||
2. **"dubious ownership in repository"** → `safe.directory '*'` konfiguriert
|
||||
3. **"No configured push destination"** → `setRemote()` und `prepareForGitea()` hinzugefügt
|
||||
4. **"src refspec main does not match any"** → Branch-Erkennung in `pushWithUpstream()` implementiert
|
||||
|
||||
---
|
||||
|
||||
## Wichtige Dateien
|
||||
|
||||
| Datei | Status |
|
||||
|-------|--------|
|
||||
| `backend/server.js` | ✅ Fertig |
|
||||
| `backend/routes/gitea.js` | ✅ Fertig |
|
||||
| `backend/routes/git.js` | ✅ Fertig (inkl. auto Default-Branch Update) |
|
||||
| `backend/services/gitService.js` | ✅ Fertig (inkl. Branch-Name Return) |
|
||||
| `backend/services/giteaService.js` | ✅ Fertig (inkl. updateRepository) |
|
||||
| `Dockerfile` | ✅ Fertig |
|
||||
| `frontend/js/api.js` | ✅ Fertig |
|
||||
| `frontend/js/gitea.js` | ✅ Fertig |
|
||||
| `frontend/css/gitea.css` | ✅ Fertig |
|
||||
| `frontend/index.html` | ✅ Fertig |
|
||||
| `frontend/js/app.js` | ✅ Fertig |
|
||||
| `frontend/sw.js` | ✅ Fertig (v119) |
|
||||
|
||||
---
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. ✅ `updateRepository()` zu `backend/services/giteaService.js` hinzugefügt
|
||||
2. ✅ Funktion exportiert
|
||||
3. ✅ Push-Flow erweitert, um Default-Branch nach Push zu aktualisieren
|
||||
4. ✅ Docker-Container neu gebaut
|
||||
5. ⏳ Testen: Projekt mit Gitea verknüpfen → Push → Dateien in Gitea sichtbar
|
||||
|
||||
---
|
||||
|
||||
## Plan-Datei
|
||||
|
||||
Der vollständige Implementierungsplan befindet sich in:
|
||||
`C:\Users\hendr\.claude\plans\rosy-stargazing-scroll.md`
|
||||
|
||||
---
|
||||
|
||||
## Befehle zum Testen
|
||||
|
||||
```bash
|
||||
# Container neu bauen
|
||||
docker compose down
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
|
||||
# Browser öffnen
|
||||
start chrome --incognito http://localhost:3000
|
||||
```
|
||||
325
PWA_TO_APK_ANLEITUNG.md
Normale Datei
325
PWA_TO_APK_ANLEITUNG.md
Normale Datei
@ -0,0 +1,325 @@
|
||||
# TaskMate PWA zu APK - Vollständige Anleitung
|
||||
|
||||
## Überblick
|
||||
|
||||
Diese Anleitung zeigt, wie Sie aus der TaskMate PWA eine Android APK erstellen, die im Google Play Store veröffentlicht werden kann.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
- TaskMate PWA ist online verfügbar (https://taskmate.aegis-sight.de)
|
||||
- Web App Manifest und Service Worker sind implementiert
|
||||
- PWA Icons in allen erforderlichen Größen vorhanden
|
||||
|
||||
## Methode 1: PWABuilder (Einfachste Methode)
|
||||
|
||||
### Schritt 1: PWA Score prüfen
|
||||
|
||||
1. Öffnen Sie https://www.pwabuilder.com
|
||||
2. Geben Sie Ihre PWA URL ein: `https://taskmate.aegis-sight.de`
|
||||
3. Klicken Sie auf "Start"
|
||||
4. PWABuilder analysiert Ihre App und zeigt den Score
|
||||
|
||||
### Schritt 2: Package für Android generieren
|
||||
|
||||
1. Klicken Sie auf "Package for stores"
|
||||
2. Wählen Sie "Android"
|
||||
3. Konfigurieren Sie die Einstellungen:
|
||||
- **Package ID**: `de.aegissight.taskmate`
|
||||
- **App Name**: TaskMate
|
||||
- **Display Mode**: Standalone
|
||||
- **Orientation**: Any
|
||||
- **Fallback URL**: `https://taskmate.aegis-sight.de`
|
||||
|
||||
### Schritt 3: Erweiterte Optionen
|
||||
|
||||
- **Signing Key**:
|
||||
- "None" für Test-APK
|
||||
- "New" für Play Store (speichern Sie den Key sicher!)
|
||||
- **Maskable Icon**: Upload des 512x512 Icons
|
||||
- **Splash Screen**: Automatisch generiert
|
||||
- **Settings**:
|
||||
- Enable notifications: Yes
|
||||
- Location permissions: No
|
||||
- WebView settings: Standard
|
||||
|
||||
### Schritt 4: Download und Test
|
||||
|
||||
1. Klicken Sie auf "Download"
|
||||
2. Extrahieren Sie die ZIP-Datei
|
||||
3. Die APK befindet sich im Ordner
|
||||
4. Installieren Sie zum Testen auf einem Android-Gerät
|
||||
|
||||
## Methode 2: Bubblewrap (Für Entwickler)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# Node.js und npm müssen installiert sein
|
||||
npm install -g @bubblewrap/cli
|
||||
```
|
||||
|
||||
### Projekt initialisieren
|
||||
|
||||
```bash
|
||||
# Neues Verzeichnis erstellen
|
||||
mkdir taskmate-android
|
||||
cd taskmate-android
|
||||
|
||||
# Bubblewrap init
|
||||
bubblewrap init --manifest https://taskmate.aegis-sight.de/manifest.json
|
||||
```
|
||||
|
||||
### Konfiguration (twa-manifest.json)
|
||||
|
||||
```json
|
||||
{
|
||||
"packageId": "de.aegissight.taskmate",
|
||||
"host": "taskmate.aegis-sight.de",
|
||||
"name": "TaskMate",
|
||||
"launcherName": "TaskMate",
|
||||
"display": "standalone",
|
||||
"themeColor": "#000000",
|
||||
"navigationColor": "#000000",
|
||||
"backgroundColor": "#000000",
|
||||
"enableNotifications": true,
|
||||
"startUrl": "/",
|
||||
"iconUrl": "https://taskmate.aegis-sight.de/assets/icons/icon-512x512.png",
|
||||
"maskableIconUrl": "https://taskmate.aegis-sight.de/assets/icons/icon-512x512.png",
|
||||
"appVersion": "1.0.0",
|
||||
"signingKey": {
|
||||
"alias": "taskmate-key",
|
||||
"path": "./taskmate-key.keystore"
|
||||
},
|
||||
"splashScreenFadeOutDuration": 300,
|
||||
"enableSiteSettingsShortcut": false,
|
||||
"shortcuts": [
|
||||
{
|
||||
"name": "Neue Aufgabe",
|
||||
"shortName": "Neue Aufgabe",
|
||||
"url": "/?action=new-task",
|
||||
"chosenIconUrl": "https://taskmate.aegis-sight.de/assets/icons/add-task-96x96.png"
|
||||
}
|
||||
],
|
||||
"fallbackType": "customtabs",
|
||||
"webManifestUrl": "https://taskmate.aegis-sight.de/manifest.json"
|
||||
}
|
||||
```
|
||||
|
||||
### Build-Prozess
|
||||
|
||||
```bash
|
||||
# Keystore erstellen (nur einmal)
|
||||
bubblewrap build --skipPwaValidation
|
||||
|
||||
# APK erstellen
|
||||
bubblewrap build
|
||||
|
||||
# Signed APK für Play Store
|
||||
bubblewrap build --skipPwaValidation
|
||||
```
|
||||
|
||||
## Methode 3: Android Studio mit TWA
|
||||
|
||||
### Projekt Setup
|
||||
|
||||
1. Erstellen Sie ein neues Android-Projekt
|
||||
2. Minimum SDK: API 19 (Android 4.4)
|
||||
3. Wählen Sie "No Activity"
|
||||
|
||||
### Dependencies (app/build.gradle)
|
||||
|
||||
```gradle
|
||||
dependencies {
|
||||
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.5.0'
|
||||
}
|
||||
```
|
||||
|
||||
### AndroidManifest.xml
|
||||
|
||||
```xml
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="de.aegissight.taskmate">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:theme="@style/Theme.AppCompat.Light.NoActionBar">
|
||||
|
||||
<activity
|
||||
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name">
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.DEFAULT_URL"
|
||||
android:value="https://taskmate.aegis-sight.de" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
|
||||
android:resource="@color/colorPrimary" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
|
||||
android:resource="@color/colorPrimary" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
|
||||
android:resource="@drawable/splash" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.SPLASH_SCREEN_FADE_OUT_DURATION"
|
||||
android:value="300" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.DISPLAY_MODE"
|
||||
android:value="standalone" />
|
||||
|
||||
<meta-data
|
||||
android:name="android.support.customtabs.trusted.SCREEN_ORIENTATION"
|
||||
android:value="unspecified" />
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data
|
||||
android:scheme="https"
|
||||
android:host="taskmate.aegis-sight.de"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="com.google.androidbrowserhelper.trusted.DelegationService"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
```
|
||||
|
||||
### Digital Asset Links
|
||||
|
||||
Erstellen Sie auf Ihrem Server die Datei:
|
||||
`https://taskmate.aegis-sight.de/.well-known/assetlinks.json`
|
||||
|
||||
```json
|
||||
[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "de.aegissight.taskmate",
|
||||
"sha256_cert_fingerprints": [
|
||||
"YOUR_APP_SIGNING_KEY_FINGERPRINT"
|
||||
]
|
||||
}
|
||||
}]
|
||||
```
|
||||
|
||||
## Icon-Generierung
|
||||
|
||||
Falls Icons fehlen, nutzen Sie diese Tools:
|
||||
|
||||
1. **Android Asset Studio**: https://romannurik.github.io/AndroidAssetStudio/
|
||||
2. **Maskable.app**: https://maskable.app/editor
|
||||
|
||||
Benötigte Größen:
|
||||
- 48x48, 72x72, 96x96, 144x144, 192x192, 512x512
|
||||
- Adaptive Icons für Android 8+
|
||||
|
||||
## Testing
|
||||
|
||||
### Lokaler Test
|
||||
1. Aktivieren Sie "Unbekannte Quellen" in Android-Einstellungen
|
||||
2. Übertragen Sie die APK aufs Gerät
|
||||
3. Installieren und testen
|
||||
|
||||
### Play Console Test Track
|
||||
1. Laden Sie die APK in die Google Play Console
|
||||
2. Erstellen Sie eine interne Testversion
|
||||
3. Fügen Sie Tester hinzu
|
||||
4. Testen Sie Installation und Updates
|
||||
|
||||
## Veröffentlichung im Play Store
|
||||
|
||||
### Vorbereitung
|
||||
1. Google Play Developer Account ($25 einmalig)
|
||||
2. App-Beschreibung und Screenshots
|
||||
3. Datenschutzerklärung
|
||||
4. Altersfreigabe-Fragebogen
|
||||
|
||||
### Store Listing
|
||||
- **Titel**: TaskMate - Aufgabenverwaltung
|
||||
- **Kurzbeschreibung**: Einfache und effiziente Aufgabenverwaltung mit Kanban-Board
|
||||
- **Kategorie**: Produktivität
|
||||
- **Screenshots**: Mindestens 2, idealerweise 8
|
||||
|
||||
### APK Upload
|
||||
1. Signierte APK erstellen
|
||||
2. In Play Console hochladen
|
||||
3. Rollout starten (gestaffelt empfohlen)
|
||||
|
||||
## Wartung und Updates
|
||||
|
||||
### Version Updates
|
||||
1. Erhöhen Sie `versionCode` und `versionName`
|
||||
2. Erstellen Sie neue APK
|
||||
3. Laden Sie in Play Console hoch
|
||||
4. Beschreiben Sie Änderungen
|
||||
|
||||
### PWA Updates
|
||||
- Service Worker Updates werden automatisch geladen
|
||||
- Manifest-Änderungen erfordern App-Update
|
||||
- Cache-Versionierung beachten
|
||||
|
||||
## Häufige Probleme
|
||||
|
||||
### "Not a valid PWA"
|
||||
- Prüfen Sie HTTPS
|
||||
- Validieren Sie manifest.json
|
||||
- Service Worker muss registriert sein
|
||||
|
||||
### "Digital Asset Links validation failed"
|
||||
- assetlinks.json muss öffentlich erreichbar sein
|
||||
- SHA256 Fingerprint muss stimmen
|
||||
- Content-Type: application/json
|
||||
|
||||
### Icon-Probleme
|
||||
- Alle Größen müssen vorhanden sein
|
||||
- PNG-Format erforderlich
|
||||
- Transparenz vermeiden für adaptive Icons
|
||||
|
||||
## Empfehlungen
|
||||
|
||||
1. **Verwenden Sie PWABuilder** für den Anfang
|
||||
2. **Bubblewrap** für mehr Kontrolle
|
||||
3. **Android Studio** nur bei speziellen Anforderungen
|
||||
4. Testen Sie auf verschiedenen Geräten
|
||||
5. Behalten Sie die Keystore-Datei sicher auf!
|
||||
|
||||
## Nächste Schritte
|
||||
|
||||
1. Icons generieren (falls noch nicht vorhanden)
|
||||
2. PWABuilder Test durchführen
|
||||
3. Test-APK erstellen und installieren
|
||||
4. Bei Erfolg: Play Store Konto einrichten
|
||||
5. Produktions-APK mit Signing Key erstellen
|
||||
6. Im Play Store veröffentlichen
|
||||
|
||||
Bei Fragen oder Problemen können Sie die offizielle Dokumentation konsultieren:
|
||||
- [PWABuilder Docs](https://docs.pwabuilder.com/)
|
||||
- [Bubblewrap GitHub](https://github.com/GoogleChromeLabs/bubblewrap)
|
||||
- [TWA Documentation](https://developers.google.com/web/android/trusted-web-activity)
|
||||
105
SSH_CLAUDE_ANLEITUNG.txt
Normale Datei
105
SSH_CLAUDE_ANLEITUNG.txt
Normale Datei
@ -0,0 +1,105 @@
|
||||
# Anleitung: Claude über SSH aus TaskMate starten
|
||||
|
||||
## Voraussetzungen
|
||||
- SSH-Client auf dem eigenen Computer
|
||||
- Zugang zu TaskMate
|
||||
|
||||
## Schritt 1: SSH-Schlüsselpaar erstellen
|
||||
|
||||
### Windows (PowerShell/Terminal):
|
||||
```
|
||||
ssh-keygen -t rsa -b 4096 -f %USERPROFILE%\.ssh\taskmate_claude
|
||||
```
|
||||
|
||||
### Mac/Linux:
|
||||
```
|
||||
ssh-keygen -t rsa -b 4096 -f ~/.ssh/taskmate_claude
|
||||
```
|
||||
|
||||
**Wichtig:**
|
||||
- Bei der Frage nach einem Passwort ein sicheres Passwort vergeben und merken
|
||||
- Es werden zwei Dateien erstellt:
|
||||
- `taskmate_claude` (privater Schlüssel - GEHEIM HALTEN!)
|
||||
- `taskmate_claude.pub` (öffentlicher Schlüssel)
|
||||
|
||||
## Schritt 2: Öffentlichen Schlüssel übermitteln
|
||||
|
||||
Den Inhalt der Datei `taskmate_claude.pub` an den Administrator senden:
|
||||
|
||||
### Windows:
|
||||
```
|
||||
type %USERPROFILE%\.ssh\taskmate_claude.pub
|
||||
```
|
||||
|
||||
### Mac/Linux:
|
||||
```
|
||||
cat ~/.ssh/taskmate_claude.pub
|
||||
```
|
||||
|
||||
Der Administrator muss diesen öffentlichen Schlüssel auf dem Server hinterlegen.
|
||||
|
||||
## Schritt 3: SSH-Konfiguration einrichten
|
||||
|
||||
Eine Konfigurationsdatei erstellen für einfacheren Zugriff:
|
||||
|
||||
### Windows:
|
||||
Datei `%USERPROFILE%\.ssh\config` bearbeiten
|
||||
|
||||
### Mac/Linux:
|
||||
Datei `~/.ssh/config` bearbeiten
|
||||
|
||||
Folgenden Inhalt hinzufügen:
|
||||
```
|
||||
Host taskmate-claude
|
||||
HostName [SERVER-IP oder DOMAIN]
|
||||
User [BENUTZERNAME]
|
||||
Port [SSH-PORT, standard 22]
|
||||
IdentityFile ~/.ssh/taskmate_claude
|
||||
```
|
||||
|
||||
**Hinweis:** Die Werte in eckigen Klammern müssen vom Administrator bereitgestellt werden.
|
||||
|
||||
## Schritt 4: Verbindung testen
|
||||
|
||||
```
|
||||
ssh taskmate-claude
|
||||
```
|
||||
|
||||
Bei der ersten Verbindung:
|
||||
1. Die Fingerprint-Warnung mit "yes" bestätigen
|
||||
2. Das beim Erstellen des Schlüssels vergebene Passwort eingeben
|
||||
|
||||
## Schritt 5: Integration in TaskMate
|
||||
|
||||
Nach erfolgreicher SSH-Konfiguration:
|
||||
1. In TaskMate einloggen
|
||||
2. Zur Kachel "TaskMate -> Claude starten" navigieren
|
||||
3. Die Funktion sollte nun verfügbar sein
|
||||
|
||||
## Sicherheitshinweise
|
||||
|
||||
- **Privaten Schlüssel niemals weitergeben!** (Datei ohne .pub Endung)
|
||||
- Das Passwort für den SSH-Schlüssel sicher aufbewahren
|
||||
- Bei Verlust des privaten Schlüssels muss ein neues Schlüsselpaar erstellt werden
|
||||
|
||||
## Fehlerbehebung
|
||||
|
||||
### "Permission denied"
|
||||
- Prüfen ob der richtige Schlüssel verwendet wird
|
||||
- Prüfen ob das Passwort korrekt ist
|
||||
|
||||
### "Connection refused"
|
||||
- Server-IP/Domain und Port prüfen
|
||||
- Firewall-Einstellungen prüfen
|
||||
|
||||
### "Host key verification failed"
|
||||
- Die Datei `~/.ssh/known_hosts` prüfen
|
||||
- Ggf. alten Eintrag für den Server entfernen
|
||||
|
||||
## Benötigte Informationen vom Administrator
|
||||
|
||||
Der Administrator muss folgende Daten bereitstellen:
|
||||
1. Server-IP oder Domain
|
||||
2. SSH-Port (falls nicht Standard 22)
|
||||
3. Benutzername für SSH-Zugang
|
||||
4. Bestätigung dass der öffentliche Schlüssel hinterlegt wurde
|
||||
@ -170,6 +170,63 @@ function createTables() {
|
||||
logger.info('Migration: repositories_base_path Spalte zu users hinzugefuegt');
|
||||
}
|
||||
|
||||
// Migration: Add custom_initials column to users
|
||||
const hasCustomInitials = userColumns.some(col => col.name === 'custom_initials');
|
||||
if (!hasCustomInitials) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN custom_initials TEXT");
|
||||
logger.info('Migration: custom_initials Spalte zu users hinzugefuegt');
|
||||
}
|
||||
|
||||
// Migration: Add initials column and prepare email
|
||||
const hasInitials = userColumns.some(col => col.name === 'initials');
|
||||
if (!hasInitials && userColumns.some(col => col.name === 'username')) {
|
||||
logger.info('Migration: Füge initials Spalte hinzu und bereite E-Mail vor');
|
||||
|
||||
// Zuerst Daten vorbereiten
|
||||
const users = db.prepare('SELECT id, username, email, custom_initials FROM users').all();
|
||||
for (const user of users) {
|
||||
// Stelle sicher dass jeder Benutzer eine E-Mail hat
|
||||
if (!user.email || user.email === '') {
|
||||
if (user.username === 'admin') {
|
||||
// Admin bekommt eine spezielle E-Mail
|
||||
db.prepare('UPDATE users SET email = ? WHERE id = ?').run('admin@taskmate.local', user.id);
|
||||
} else if (user.username.includes('@')) {
|
||||
// Username enthält bereits E-Mail (wie bei bestehenden Benutzern)
|
||||
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(user.username, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialen setzen (aus custom_initials oder generieren)
|
||||
if (!user.custom_initials || user.custom_initials === '') {
|
||||
let initials = 'XX';
|
||||
if (user.username === 'admin') {
|
||||
initials = 'AD';
|
||||
} else if (user.email || user.username.includes('@')) {
|
||||
// Generiere Initialen aus E-Mail
|
||||
const emailPart = (user.email || user.username).split('@')[0];
|
||||
if (emailPart.includes('_')) {
|
||||
const parts = emailPart.split('_');
|
||||
initials = (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
} else if (emailPart.includes('.')) {
|
||||
const parts = emailPart.split('.');
|
||||
initials = (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
} else {
|
||||
initials = emailPart.substring(0, 2).toUpperCase();
|
||||
}
|
||||
}
|
||||
db.prepare('UPDATE users SET custom_initials = ? WHERE id = ?').run(initials, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Neue initials Spalte hinzufügen
|
||||
db.exec("ALTER TABLE users ADD COLUMN initials TEXT");
|
||||
|
||||
// Daten von custom_initials nach initials kopieren
|
||||
db.exec("UPDATE users SET initials = custom_initials");
|
||||
|
||||
logger.info('Migration: initials Spalte hinzugefügt und E-Mail-Daten vorbereitet');
|
||||
}
|
||||
|
||||
// Proposals (Vorschlaege)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proposals (
|
||||
@ -368,6 +425,25 @@ function createTables() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Refresh Tokens für sichere Token-Rotation
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_used DATETIME,
|
||||
user_agent TEXT,
|
||||
ip_address TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Index für Token-Lookup
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token)`);
|
||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_refresh_tokens_expires ON refresh_tokens(expires_at)`);
|
||||
|
||||
// Anwendungen (Git-Repositories pro Projekt)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
@ -409,6 +485,159 @@ function createTables() {
|
||||
)
|
||||
`);
|
||||
|
||||
// Erinnerungen
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reminders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
reminder_date DATE NOT NULL,
|
||||
reminder_time TIME DEFAULT '09:00',
|
||||
color TEXT DEFAULT '#F59E0B',
|
||||
advance_days TEXT DEFAULT '1',
|
||||
repeat_type TEXT DEFAULT 'none',
|
||||
repeat_interval INTEGER DEFAULT 1,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Erinnerungs-Benachrichtigungen (für Tracking welche bereits gesendet wurden)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reminder_notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reminder_id INTEGER NOT NULL,
|
||||
notification_date DATE NOT NULL,
|
||||
sent INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (reminder_id) REFERENCES reminders(id) ON DELETE CASCADE,
|
||||
UNIQUE(reminder_id, notification_date)
|
||||
)
|
||||
`);
|
||||
|
||||
// Wissensmanagement - Kategorien
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS knowledge_categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
color TEXT DEFAULT '#3B82F6',
|
||||
icon TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Wissensmanagement - Einträge
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS knowledge_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
category_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
url TEXT,
|
||||
notes TEXT,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (category_id) REFERENCES knowledge_categories(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Wissensmanagement - Anhänge
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS knowledge_attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
entry_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
uploaded_by INTEGER,
|
||||
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (entry_id) REFERENCES knowledge_entries(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Coding-Verzeichnisse (projektübergreifend)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS coding_directories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
local_path TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
color TEXT DEFAULT '#4F46E5',
|
||||
gitea_repo_url TEXT,
|
||||
gitea_repo_owner TEXT,
|
||||
gitea_repo_name TEXT,
|
||||
default_branch TEXT DEFAULT 'main',
|
||||
last_sync DATETIME,
|
||||
position INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add claude_instructions column to coding_directories
|
||||
const codingDirColumns = db.prepare("PRAGMA table_info(coding_directories)").all();
|
||||
const hasClaudeInstructions = codingDirColumns.some(col => col.name === 'claude_instructions');
|
||||
if (!hasClaudeInstructions) {
|
||||
db.exec('ALTER TABLE coding_directories ADD COLUMN claude_instructions TEXT');
|
||||
logger.info('Migration: claude_instructions Spalte zu coding_directories hinzugefuegt');
|
||||
}
|
||||
|
||||
// Coding Verbrauchsdaten
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS coding_usage (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
directory_id INTEGER NOT NULL,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
cpu_percent REAL,
|
||||
memory_mb REAL,
|
||||
disk_read_mb REAL,
|
||||
disk_write_mb REAL,
|
||||
network_recv_mb REAL,
|
||||
network_sent_mb REAL,
|
||||
process_count INTEGER,
|
||||
FOREIGN KEY (directory_id) REFERENCES coding_directories(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Kontakte
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS contacts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
first_name TEXT,
|
||||
last_name TEXT,
|
||||
company TEXT,
|
||||
position TEXT,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
mobile TEXT,
|
||||
address TEXT,
|
||||
postal_code TEXT,
|
||||
city TEXT,
|
||||
country TEXT,
|
||||
website TEXT,
|
||||
notes TEXT,
|
||||
tags TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Indizes für Performance
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
||||
@ -426,17 +655,42 @@ function createTables() {
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_project ON applications(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_entries_category ON knowledge_entries(category_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_attachments_entry ON knowledge_attachments(entry_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_coding_directories_position ON coding_directories(position);
|
||||
CREATE INDEX IF NOT EXISTS idx_coding_usage_directory ON coding_usage(directory_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_coding_usage_timestamp ON coding_usage(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_company ON contacts(company);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_tags ON contacts(tags);
|
||||
`);
|
||||
|
||||
logger.info('Datenbank-Tabellen erstellt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Benutzer erstellen
|
||||
* Standard-Benutzer erstellen und Admin-Passwort korrigieren
|
||||
*/
|
||||
async function createDefaultUsers() {
|
||||
const existingUsers = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
|
||||
// Admin-Passwort korrigieren (falls aus .env verschieden)
|
||||
const adminExists = db.prepare('SELECT id, password_hash FROM users WHERE email = ? AND role = ?').get('admin@taskmate.local', 'admin');
|
||||
if (adminExists) {
|
||||
const correctAdminPassword = process.env.ADMIN_PASSWORD || 'admin123';
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
// Prüfen ob das Passwort bereits korrekt ist
|
||||
const isCorrect = await bcrypt.compare(correctAdminPassword, adminExists.password_hash);
|
||||
if (!isCorrect) {
|
||||
logger.info('Admin-Passwort wird aus .env aktualisiert');
|
||||
const correctHash = await bcrypt.hash(correctAdminPassword, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(correctHash, adminExists.id);
|
||||
logger.info('Admin-Passwort erfolgreich aktualisiert');
|
||||
} else {
|
||||
logger.info('Admin-Passwort bereits korrekt');
|
||||
}
|
||||
}
|
||||
|
||||
if (existingUsers.count === 0) {
|
||||
// Benutzer aus Umgebungsvariablen
|
||||
const user1 = {
|
||||
@ -460,10 +714,10 @@ async function createDefaultUsers() {
|
||||
|
||||
// Admin-Benutzer
|
||||
const adminUser = {
|
||||
username: 'admin',
|
||||
password: '!1Data123',
|
||||
displayName: 'Administrator',
|
||||
color: '#8B5CF6'
|
||||
username: process.env.ADMIN_USERNAME || 'admin',
|
||||
password: process.env.ADMIN_PASSWORD || 'admin123',
|
||||
displayName: process.env.ADMIN_DISPLAYNAME || 'Administrator',
|
||||
color: process.env.ADMIN_COLOR || '#8B5CF6'
|
||||
};
|
||||
|
||||
// Passwoerter hashen und Benutzer erstellen
|
||||
|
||||
@ -5,15 +5,22 @@
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
const { getDb } = require('../database');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'UNSICHER_BITTE_AENDERN';
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||
throw new Error('JWT_SECRET muss in .env gesetzt und mindestens 32 Zeichen lang sein!');
|
||||
}
|
||||
const ACCESS_TOKEN_EXPIRY = 15; // Minuten (kürzer für mehr Sicherheit)
|
||||
const REFRESH_TOKEN_EXPIRY = 7 * 24 * 60; // 7 Tage in Minuten
|
||||
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
|
||||
|
||||
/**
|
||||
* JWT-Token generieren
|
||||
* JWT Access-Token generieren (kurze Lebensdauer)
|
||||
*/
|
||||
function generateToken(user) {
|
||||
function generateAccessToken(user) {
|
||||
// Permissions parsen falls als String gespeichert
|
||||
let permissions = user.permissions || [];
|
||||
if (typeof permissions === 'string') {
|
||||
@ -31,13 +38,38 @@ function generateToken(user) {
|
||||
displayName: user.display_name,
|
||||
color: user.color,
|
||||
role: user.role || 'user',
|
||||
permissions: permissions
|
||||
permissions: permissions,
|
||||
type: 'access'
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: `${SESSION_TIMEOUT}m` }
|
||||
{ expiresIn: `${ACCESS_TOKEN_EXPIRY}m` }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh-Token generieren (lange Lebensdauer)
|
||||
*/
|
||||
function generateRefreshToken(userId, ipAddress, userAgent) {
|
||||
const db = getDb();
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiresAt = new Date(Date.now() + REFRESH_TOKEN_EXPIRY * 60 * 1000);
|
||||
|
||||
// Token in Datenbank speichern
|
||||
db.prepare(`
|
||||
INSERT INTO refresh_tokens (user_id, token, expires_at, ip_address, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(userId, token, expiresAt.toISOString(), ipAddress, userAgent);
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy generateToken für Rückwärtskompatibilität
|
||||
*/
|
||||
function generateToken(user) {
|
||||
return generateAccessToken(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT-Token verifizieren
|
||||
*/
|
||||
@ -45,6 +77,10 @@ function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (error) {
|
||||
// Nur bei unerwarteten Fehlern loggen (nicht bei normalen Ablauf/Ungültig-Fällen)
|
||||
if (error.name !== 'TokenExpiredError' && error.name !== 'JsonWebTokenError') {
|
||||
logger.error(`[AUTH] Unerwarteter Token-Fehler: ${error.name} - ${error.message}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@ -175,8 +211,72 @@ function generateCsrfToken() {
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh-Token validieren und neuen Access-Token generieren
|
||||
*/
|
||||
async function refreshAccessToken(refreshToken, ipAddress, userAgent) {
|
||||
const db = getDb();
|
||||
|
||||
// Token in Datenbank suchen
|
||||
const tokenRecord = db.prepare(`
|
||||
SELECT rt.*, u.* FROM refresh_tokens rt
|
||||
JOIN users u ON rt.user_id = u.id
|
||||
WHERE rt.token = ? AND rt.expires_at > datetime('now')
|
||||
`).get(refreshToken);
|
||||
|
||||
if (!tokenRecord) {
|
||||
throw new Error('Ungültiger oder abgelaufener Refresh-Token');
|
||||
}
|
||||
|
||||
// Token als benutzt markieren
|
||||
db.prepare(`
|
||||
UPDATE refresh_tokens SET last_used = CURRENT_TIMESTAMP WHERE id = ?
|
||||
`).run(tokenRecord.id);
|
||||
|
||||
// Neuen Access-Token generieren
|
||||
const user = {
|
||||
id: tokenRecord.user_id,
|
||||
username: tokenRecord.username,
|
||||
display_name: tokenRecord.display_name,
|
||||
color: tokenRecord.color,
|
||||
role: tokenRecord.role,
|
||||
permissions: tokenRecord.permissions
|
||||
};
|
||||
|
||||
return generateAccessToken(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Refresh-Tokens eines Benutzers löschen (Logout auf allen Geräten)
|
||||
*/
|
||||
function revokeAllRefreshTokens(userId) {
|
||||
const db = getDb();
|
||||
db.prepare('DELETE FROM refresh_tokens WHERE user_id = ?').run(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgelaufene Refresh-Tokens aufräumen
|
||||
*/
|
||||
function cleanupExpiredTokens() {
|
||||
const db = getDb();
|
||||
const result = db.prepare(`
|
||||
DELETE FROM refresh_tokens WHERE expires_at < datetime('now')
|
||||
`).run();
|
||||
|
||||
if (result.changes > 0) {
|
||||
logger.info(`Bereinigt: ${result.changes} abgelaufene Refresh-Tokens`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup alle 6 Stunden
|
||||
setInterval(cleanupExpiredTokens, 6 * 60 * 60 * 1000);
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
generateAccessToken,
|
||||
generateRefreshToken,
|
||||
refreshAccessToken,
|
||||
revokeAllRefreshTokens,
|
||||
verifyToken,
|
||||
authenticateToken,
|
||||
authenticateSocket,
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
@ -18,18 +19,54 @@ if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Mapping: Dateiendung -> erlaubte MIME-Types
|
||||
const EXTENSION_TO_MIME = {
|
||||
// Dokumente
|
||||
'pdf': ['application/pdf'],
|
||||
'doc': ['application/msword'],
|
||||
'docx': ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'],
|
||||
'xls': ['application/vnd.ms-excel'],
|
||||
'xlsx': ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'],
|
||||
'ppt': ['application/vnd.ms-powerpoint'],
|
||||
'pptx': ['application/vnd.openxmlformats-officedocument.presentationml.presentation'],
|
||||
'odt': ['application/vnd.oasis.opendocument.text'],
|
||||
'ods': ['application/vnd.oasis.opendocument.spreadsheet'],
|
||||
'odp': ['application/vnd.oasis.opendocument.presentation'],
|
||||
'rtf': ['application/rtf', 'text/rtf'],
|
||||
// Text
|
||||
'txt': ['text/plain'],
|
||||
'csv': ['text/csv', 'application/csv', 'text/comma-separated-values'],
|
||||
'md': ['text/markdown', 'text/x-markdown', 'text/plain'],
|
||||
'json': ['application/json', 'text/json'],
|
||||
'xml': ['application/xml', 'text/xml'],
|
||||
'html': ['text/html'],
|
||||
'log': ['text/plain'],
|
||||
// Bilder
|
||||
'jpg': ['image/jpeg'],
|
||||
'jpeg': ['image/jpeg'],
|
||||
'png': ['image/png'],
|
||||
'gif': ['image/gif'],
|
||||
'webp': ['image/webp'],
|
||||
'svg': ['image/svg+xml'],
|
||||
'bmp': ['image/bmp'],
|
||||
'ico': ['image/x-icon', 'image/vnd.microsoft.icon'],
|
||||
// Archive
|
||||
'zip': ['application/zip', 'application/x-zip-compressed'],
|
||||
'rar': ['application/x-rar-compressed', 'application/vnd.rar'],
|
||||
'7z': ['application/x-7z-compressed'],
|
||||
'tar': ['application/x-tar'],
|
||||
'gz': ['application/gzip', 'application/x-gzip'],
|
||||
// Code/Skripte (als text/plain akzeptiert)
|
||||
'sql': ['application/sql', 'text/plain'],
|
||||
'js': ['text/javascript', 'application/javascript', 'text/plain'],
|
||||
'css': ['text/css', 'text/plain'],
|
||||
'py': ['text/x-python', 'text/plain'],
|
||||
'sh': ['application/x-sh', 'text/plain']
|
||||
};
|
||||
|
||||
// Standard-Werte (Fallback)
|
||||
let MAX_FILE_SIZE = (parseInt(process.env.MAX_FILE_SIZE_MB) || 15) * 1024 * 1024;
|
||||
let ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/plain', 'text/csv', 'text/markdown',
|
||||
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
|
||||
'application/json'
|
||||
];
|
||||
let ALLOWED_EXTENSIONS = ['pdf', 'docx', 'txt'];
|
||||
|
||||
/**
|
||||
* Lädt Upload-Einstellungen aus der Datenbank
|
||||
@ -43,17 +80,9 @@ function loadUploadSettings() {
|
||||
if (settings) {
|
||||
MAX_FILE_SIZE = (settings.maxFileSizeMB || 15) * 1024 * 1024;
|
||||
|
||||
// Erlaubte MIME-Types aus den aktiven Kategorien zusammenstellen
|
||||
const types = [];
|
||||
if (settings.allowedTypes) {
|
||||
Object.values(settings.allowedTypes).forEach(category => {
|
||||
if (category.enabled && Array.isArray(category.types)) {
|
||||
types.push(...category.types);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (types.length > 0) {
|
||||
ALLOWED_MIME_TYPES = types;
|
||||
// Erlaubte Endungen aus den Einstellungen
|
||||
if (Array.isArray(settings.allowedExtensions) && settings.allowedExtensions.length > 0) {
|
||||
ALLOWED_EXTENSIONS = settings.allowedExtensions;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@ -67,7 +96,7 @@ function loadUploadSettings() {
|
||||
*/
|
||||
function getCurrentSettings() {
|
||||
loadUploadSettings();
|
||||
return { maxFileSize: MAX_FILE_SIZE, allowedMimeTypes: ALLOWED_MIME_TYPES };
|
||||
return { maxFileSize: MAX_FILE_SIZE, allowedExtensions: ALLOWED_EXTENSIONS };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -99,19 +128,83 @@ const storage = multer.diskStorage({
|
||||
});
|
||||
|
||||
/**
|
||||
* Datei-Filter
|
||||
* Gefährliche Dateinamen prüfen
|
||||
*/
|
||||
function isSecureFilename(filename) {
|
||||
// Null-Bytes, Pfad-Traversal, Steuerzeichen blocken
|
||||
const dangerousPatterns = [
|
||||
/\x00/, // Null-Bytes
|
||||
/\.\./, // Path traversal
|
||||
/[<>:"\\|?*]/, // Windows-spezifische gefährliche Zeichen
|
||||
/[\x00-\x1F]/, // Steuerzeichen
|
||||
/^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i, // Windows reservierte Namen
|
||||
];
|
||||
|
||||
return !dangerousPatterns.some(pattern => pattern.test(filename));
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei-Filter: Erweiterte Sicherheitsprüfungen
|
||||
*/
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// Aktuelle Einstellungen laden
|
||||
const settings = getCurrentSettings();
|
||||
|
||||
// MIME-Type prüfen
|
||||
if (settings.allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
logger.warn(`Abgelehnter Upload: ${file.originalname} (${file.mimetype})`);
|
||||
cb(new Error(`Dateityp nicht erlaubt: ${file.mimetype}`), false);
|
||||
// Sicherheitsprüfungen für Dateinamen
|
||||
if (!isSecureFilename(file.originalname)) {
|
||||
logger.warn(`Unsicherer Dateiname abgelehnt: ${file.originalname}`);
|
||||
cb(new Error('Dateiname enthält nicht erlaubte Zeichen'), false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dateiname-Länge prüfen
|
||||
if (file.originalname.length > 255) {
|
||||
logger.warn(`Dateiname zu lang: ${file.originalname}`);
|
||||
cb(new Error('Dateiname ist zu lang (max. 255 Zeichen)'), false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dateiendung extrahieren (ohne Punkt, lowercase)
|
||||
const ext = path.extname(file.originalname).toLowerCase().replace('.', '');
|
||||
|
||||
// Doppelte Dateiendungen verhindern (z.B. script.txt.exe)
|
||||
const nameWithoutExt = path.basename(file.originalname, path.extname(file.originalname));
|
||||
if (path.extname(nameWithoutExt)) {
|
||||
logger.warn(`Doppelte Dateiendung abgelehnt: ${file.originalname}`);
|
||||
cb(new Error('Dateien mit mehreren Endungen sind nicht erlaubt'), false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Prüfen ob Endung erlaubt ist
|
||||
if (!settings.allowedExtensions.includes(ext)) {
|
||||
logger.warn(`Abgelehnter Upload (Endung): ${file.originalname} (.${ext})`);
|
||||
cb(new Error(`Dateityp .${ext} nicht erlaubt`), false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Executable Dateien zusätzlich blocken
|
||||
const executableExtensions = [
|
||||
'exe', 'bat', 'cmd', 'com', 'scr', 'pif', 'vbs', 'vbe', 'js', 'jar',
|
||||
'app', 'deb', 'pkg', 'dmg', 'run', 'bin', 'msi', 'gadget'
|
||||
];
|
||||
if (executableExtensions.includes(ext)) {
|
||||
logger.warn(`Executable Datei abgelehnt: ${file.originalname}`);
|
||||
cb(new Error('Ausführbare Dateien sind nicht erlaubt'), false);
|
||||
return;
|
||||
}
|
||||
|
||||
// MIME-Type gegen bekannte Typen prüfen
|
||||
const expectedMimes = EXTENSION_TO_MIME[ext];
|
||||
if (expectedMimes && !expectedMimes.includes(file.mimetype)) {
|
||||
logger.warn(`MIME-Mismatch: ${file.originalname} (erwartet: ${expectedMimes.join('/')}, bekommen: ${file.mimetype})`);
|
||||
// Bei kritischen Mismatches ablehnen
|
||||
if (file.mimetype === 'application/octet-stream' || file.mimetype.startsWith('application/x-')) {
|
||||
cb(new Error('Verdächtiger Dateityp erkannt'), false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
cb(null, true);
|
||||
};
|
||||
|
||||
/**
|
||||
@ -194,5 +287,6 @@ module.exports = {
|
||||
getCurrentSettings,
|
||||
UPLOAD_DIR,
|
||||
MAX_FILE_SIZE,
|
||||
ALLOWED_MIME_TYPES
|
||||
ALLOWED_EXTENSIONS,
|
||||
EXTENSION_TO_MIME
|
||||
};
|
||||
|
||||
@ -5,24 +5,52 @@
|
||||
*/
|
||||
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
const createDOMPurify = require('dompurify');
|
||||
const { JSDOM } = require('jsdom');
|
||||
|
||||
// DOMPurify für Server-side Rendering initialisieren
|
||||
const window = new JSDOM('').window;
|
||||
const DOMPurify = createDOMPurify(window);
|
||||
|
||||
/**
|
||||
* HTML-Tags entfernen (für reine Text-Felder)
|
||||
* HTML-Entities dekodieren
|
||||
*/
|
||||
function stripHtml(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
return sanitizeHtml(input, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
}).trim();
|
||||
function decodeHtmlEntities(str) {
|
||||
if (typeof str !== 'string') return str;
|
||||
const entities = {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
''': "'",
|
||||
''': "'",
|
||||
''': "'"
|
||||
};
|
||||
return str.replace(/&(amp|lt|gt|quot|#039|#x27|apos);/g, match => entities[match] || match);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown-sichere Bereinigung (erlaubt bestimmte Tags)
|
||||
* HTML-Tags entfernen (für reine Text-Felder)
|
||||
* Wichtig: sanitize-html encoded &-Zeichen zu &, daher dekodieren wir danach
|
||||
*/
|
||||
function stripHtml(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
const sanitized = sanitizeHtml(input, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
}).trim();
|
||||
// Entities wieder dekodieren, da sanitize-html sie encoded
|
||||
return decodeHtmlEntities(sanitized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown-sichere Bereinigung mit DOMPurify (doppelte Sicherheit)
|
||||
*/
|
||||
function sanitizeMarkdown(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
return sanitizeHtml(input, {
|
||||
|
||||
// Erste Bereinigung mit sanitize-html
|
||||
const firstPass = sanitizeHtml(input, {
|
||||
allowedTags: [
|
||||
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||
@ -44,6 +72,16 @@ function sanitizeMarkdown(input) {
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Zweite Bereinigung mit DOMPurify (zusätzliche Sicherheit)
|
||||
return DOMPurify.sanitize(firstPass, {
|
||||
ALLOWED_TAGS: [
|
||||
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'title', 'target', 'rel'],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:(?:f|ht)tps?|mailto):|[^a-z]|[a-z+.-]+(?:[^a-z+.-:]|$))/i
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@ -65,7 +103,15 @@ function sanitizeObject(obj, options = {}) {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Bestimmte Felder dürfen Markdown enthalten
|
||||
const allowHtml = ['description', 'content'].includes(key);
|
||||
sanitized[key] = sanitizeObject(value, { allowHtml });
|
||||
|
||||
// Passwort-Felder NICHT sanitizen (Sonderzeichen erhalten)
|
||||
const skipSanitization = ['password', 'oldPassword', 'newPassword', 'confirmPassword'].includes(key);
|
||||
|
||||
if (skipSanitization) {
|
||||
sanitized[key] = value; // Passwort unverändert lassen
|
||||
} else {
|
||||
sanitized[key] = sanitizeObject(value, { allowHtml });
|
||||
}
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
@ -119,12 +165,32 @@ const validators = {
|
||||
},
|
||||
|
||||
/**
|
||||
* URL-Format prüfen
|
||||
* URL-Format prüfen (erweiterte Sicherheit)
|
||||
*/
|
||||
url: (value, fieldName) => {
|
||||
try {
|
||||
if (value) {
|
||||
new URL(value);
|
||||
const url = new URL(value);
|
||||
|
||||
// Nur HTTP/HTTPS erlauben
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return `${fieldName} muss HTTP oder HTTPS verwenden`;
|
||||
}
|
||||
|
||||
// Localhost und private IPs blocken (SSRF-Schutz)
|
||||
const hostname = url.hostname;
|
||||
if (hostname === 'localhost' ||
|
||||
hostname === '127.0.0.1' ||
|
||||
hostname.startsWith('192.168.') ||
|
||||
hostname.startsWith('10.') ||
|
||||
hostname.startsWith('172.')) {
|
||||
return `${fieldName} darf nicht auf lokale Adressen verweisen`;
|
||||
}
|
||||
|
||||
// JavaScript URLs blocken
|
||||
if (url.href.toLowerCase().startsWith('javascript:')) {
|
||||
return `${fieldName} enthält ungültigen JavaScript-Code`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
@ -239,6 +305,46 @@ function sanitizeMiddleware(req, res, next) {
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontakt-Validierung Middleware
|
||||
*/
|
||||
validators.contact = function(req, res, next) {
|
||||
const errors = [];
|
||||
const { firstName, lastName, company, email, phone, mobile, website } = req.body;
|
||||
|
||||
// Mindestens ein Name oder Firma muss vorhanden sein
|
||||
if (!firstName && !lastName && !company) {
|
||||
errors.push('Mindestens Vorname, Nachname oder Firma muss angegeben werden');
|
||||
}
|
||||
|
||||
// Email validieren
|
||||
if (email) {
|
||||
const emailError = validators.email(email, 'E-Mail');
|
||||
if (emailError) errors.push(emailError);
|
||||
}
|
||||
|
||||
// Website URL validieren
|
||||
if (website) {
|
||||
const urlError = validators.url(website, 'Website');
|
||||
if (urlError) errors.push(urlError);
|
||||
}
|
||||
|
||||
// Telefonnummer Format (optional)
|
||||
if (phone && !/^[\d\s\-\+\(\)]+$/.test(phone)) {
|
||||
errors.push('Telefonnummer enthält ungültige Zeichen');
|
||||
}
|
||||
|
||||
if (mobile && !/^[\d\s\-\+\(\)]+$/.test(mobile)) {
|
||||
errors.push('Mobilnummer enthält ungültige Zeichen');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return res.status(400).json({ errors });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
stripHtml,
|
||||
sanitizeMarkdown,
|
||||
|
||||
@ -20,7 +20,10 @@
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express-rate-limiter": "^1.3.1",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"marked": "^11.1.0"
|
||||
"marked": "^11.1.0",
|
||||
"dompurify": "^3.0.6",
|
||||
"jsdom": "^23.0.1",
|
||||
"dotenv": "^16.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
|
||||
87
backend/query_users.js
Normale Datei
87
backend/query_users.js
Normale Datei
@ -0,0 +1,87 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Script zum Abfragen der Benutzer aus der SQLite-Datenbank
|
||||
* Verwendung: node query_users.js
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
// Datenbank-Pfad - angepasst für Docker-Container
|
||||
const DB_PATH = process.env.DB_PATH || './data/taskmate.db';
|
||||
|
||||
try {
|
||||
console.log('Verbinde zur Datenbank:', DB_PATH);
|
||||
|
||||
// Datenbank öffnen
|
||||
const db = new Database(DB_PATH);
|
||||
|
||||
// Alle Benutzer abfragen
|
||||
const users = db.prepare(`
|
||||
SELECT
|
||||
id,
|
||||
username,
|
||||
display_name,
|
||||
color,
|
||||
role,
|
||||
email,
|
||||
repositories_base_path,
|
||||
created_at,
|
||||
last_login,
|
||||
failed_attempts,
|
||||
locked_until
|
||||
FROM users
|
||||
ORDER BY id
|
||||
`).all();
|
||||
|
||||
console.log('\n=== BENUTZER IN DER DATENBANK ===\n');
|
||||
|
||||
if (users.length === 0) {
|
||||
console.log('Keine Benutzer gefunden!');
|
||||
} else {
|
||||
users.forEach(user => {
|
||||
console.log(`ID: ${user.id}`);
|
||||
console.log(`Benutzername: ${user.username}`);
|
||||
console.log(`Anzeigename: ${user.display_name}`);
|
||||
console.log(`Farbe: ${user.color}`);
|
||||
console.log(`Rolle: ${user.role || 'user'}`);
|
||||
console.log(`E-Mail: ${user.email || 'nicht gesetzt'}`);
|
||||
console.log(`Repository-Basispfad: ${user.repositories_base_path || 'nicht gesetzt'}`);
|
||||
console.log(`Erstellt am: ${user.created_at}`);
|
||||
console.log(`Letzter Login: ${user.last_login || 'noch nie'}`);
|
||||
console.log(`Fehlgeschlagene Versuche: ${user.failed_attempts}`);
|
||||
console.log(`Gesperrt bis: ${user.locked_until || 'nicht gesperrt'}`);
|
||||
console.log('-----------------------------------');
|
||||
});
|
||||
|
||||
console.log(`\nGesamt: ${users.length} Benutzer gefunden`);
|
||||
}
|
||||
|
||||
// Prüfe auch Login-Audit für weitere Informationen
|
||||
const recentAttempts = db.prepare(`
|
||||
SELECT
|
||||
la.timestamp,
|
||||
la.ip_address,
|
||||
la.success,
|
||||
la.user_agent,
|
||||
u.username
|
||||
FROM login_audit la
|
||||
LEFT JOIN users u ON la.user_id = u.id
|
||||
ORDER BY la.timestamp DESC
|
||||
LIMIT 10
|
||||
`).all();
|
||||
|
||||
if (recentAttempts.length > 0) {
|
||||
console.log('\n=== LETZTE LOGIN-VERSUCHE ===\n');
|
||||
recentAttempts.forEach(attempt => {
|
||||
console.log(`${attempt.timestamp}: ${attempt.username || 'Unbekannt'} - ${attempt.success ? 'ERFOLGREICH' : 'FEHLGESCHLAGEN'} - IP: ${attempt.ip_address}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Datenbank schließen
|
||||
db.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abfragen der Datenbank:', error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
@ -10,45 +10,14 @@ const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
const backup = require('../utils/backup');
|
||||
|
||||
/**
|
||||
* Standard-Upload-Einstellungen
|
||||
* Standard-Upload-Einstellungen (neues Format mit Dateiendungen)
|
||||
*/
|
||||
const DEFAULT_UPLOAD_SETTINGS = {
|
||||
maxFileSizeMB: 15,
|
||||
allowedTypes: {
|
||||
images: {
|
||||
enabled: true,
|
||||
types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
},
|
||||
documents: {
|
||||
enabled: true,
|
||||
types: ['application/pdf']
|
||||
},
|
||||
office: {
|
||||
enabled: true,
|
||||
types: [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
]
|
||||
},
|
||||
text: {
|
||||
enabled: true,
|
||||
types: ['text/plain', 'text/csv', 'text/markdown']
|
||||
},
|
||||
archives: {
|
||||
enabled: true,
|
||||
types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed']
|
||||
},
|
||||
data: {
|
||||
enabled: true,
|
||||
types: ['application/json']
|
||||
}
|
||||
}
|
||||
allowedExtensions: ['pdf', 'docx', 'txt']
|
||||
};
|
||||
|
||||
// Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle
|
||||
@ -62,8 +31,8 @@ router.get('/users', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const users = db.prepare(`
|
||||
SELECT id, username, display_name, color, role, permissions, email,
|
||||
created_at, last_login, failed_attempts, locked_until
|
||||
SELECT id, email, initials, display_name, color, role, permissions,
|
||||
created_at, last_login, failed_attempts, locked_until, custom_initials
|
||||
FROM users
|
||||
ORDER BY id
|
||||
`).all();
|
||||
@ -86,10 +55,10 @@ router.get('/users', (req, res) => {
|
||||
*/
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const { username, password, displayName, email, role, permissions } = req.body;
|
||||
const { initials, password, displayName, email, role, permissions } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (!username || !password || !displayName || !email) {
|
||||
if (!initials || !password || !displayName || !email) {
|
||||
return res.status(400).json({ error: 'Kürzel, Passwort, Anzeigename und E-Mail erforderlich' });
|
||||
}
|
||||
|
||||
@ -100,8 +69,8 @@ router.post('/users', async (req, res) => {
|
||||
}
|
||||
|
||||
// Kürzel muss genau 2 Buchstaben sein
|
||||
const usernameUpper = username.toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(usernameUpper)) {
|
||||
const initialsUpper = initials.toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(initialsUpper)) {
|
||||
return res.status(400).json({ error: 'Kürzel muss genau 2 Buchstaben sein (z.B. HG)' });
|
||||
}
|
||||
|
||||
@ -111,18 +80,18 @@ router.post('/users', async (req, res) => {
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prüfen ob Kürzel bereits existiert
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(usernameUpper);
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Kürzel bereits vergeben' });
|
||||
}
|
||||
|
||||
// Prüfen ob E-Mail bereits existiert
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({ error: 'E-Mail bereits vergeben' });
|
||||
}
|
||||
|
||||
// Prüfen ob Kürzel bereits existiert
|
||||
const existingInitials = db.prepare('SELECT id FROM users WHERE initials = ?').get(initialsUpper);
|
||||
if (existingInitials) {
|
||||
return res.status(400).json({ error: 'Kürzel bereits vergeben' });
|
||||
}
|
||||
|
||||
// Passwort hashen
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
@ -131,23 +100,24 @@ router.post('/users', async (req, res) => {
|
||||
|
||||
// Benutzer erstellen
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (username, password_hash, display_name, color, role, permissions, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (email, initials, password_hash, display_name, color, role, permissions, custom_initials)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
usernameUpper,
|
||||
email.toLowerCase(),
|
||||
initialsUpper,
|
||||
passwordHash,
|
||||
displayName,
|
||||
defaultColor,
|
||||
role || 'user',
|
||||
JSON.stringify(permissions || []),
|
||||
email.toLowerCase()
|
||||
initialsUpper
|
||||
);
|
||||
|
||||
logger.info(`Admin ${req.user.username} hat Benutzer ${usernameUpper} erstellt`);
|
||||
logger.info(`Admin ${req.user.email} hat Benutzer ${initialsUpper} erstellt`);
|
||||
|
||||
res.status(201).json({
|
||||
id: result.lastInsertRowid,
|
||||
username: usernameUpper,
|
||||
initials: initialsUpper,
|
||||
displayName,
|
||||
email: email.toLowerCase(),
|
||||
color: defaultColor,
|
||||
@ -166,7 +136,7 @@ router.post('/users', async (req, res) => {
|
||||
router.put('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
const { displayName, color, role, permissions, password, unlockAccount, email } = req.body;
|
||||
const { displayName, color, role, permissions, password, unlockAccount, email, initials } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
@ -237,6 +207,24 @@ router.put('/users/:id', async (req, res) => {
|
||||
params.push(email.toLowerCase());
|
||||
}
|
||||
|
||||
if (initials !== undefined) {
|
||||
// Validierung: Genau 2 Buchstaben
|
||||
const initialsUpper = initials.toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(initialsUpper)) {
|
||||
return res.status(400).json({ error: 'Kürzel muss genau 2 Buchstaben sein (z.B. HG)' });
|
||||
}
|
||||
// Prüfen ob Kürzel bereits von anderem Benutzer verwendet wird
|
||||
const existingInitials = db.prepare('SELECT id FROM users WHERE initials = ? AND id != ?').get(initialsUpper, userId);
|
||||
if (existingInitials) {
|
||||
return res.status(400).json({ error: 'Kürzel bereits vergeben' });
|
||||
}
|
||||
updates.push('initials = ?');
|
||||
params.push(initialsUpper);
|
||||
// Auch custom_initials aktualisieren für Kompatibilität
|
||||
updates.push('custom_initials = ?');
|
||||
params.push(initialsUpper);
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine Änderungen angegeben' });
|
||||
}
|
||||
@ -249,7 +237,7 @@ router.put('/users/:id', async (req, res) => {
|
||||
// Aktualisierten Benutzer zurueckgeben
|
||||
const updatedUser = db.prepare(`
|
||||
SELECT id, username, display_name, color, role, permissions, email,
|
||||
created_at, last_login, failed_attempts, locked_until
|
||||
created_at, last_login, failed_attempts, locked_until, custom_initials
|
||||
FROM users WHERE id = ?
|
||||
`).get(userId);
|
||||
|
||||
@ -290,35 +278,44 @@ router.delete('/users/:id', (req, res) => {
|
||||
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
|
||||
}
|
||||
|
||||
// Alle Referenzen auf den Benutzer auf NULL setzen oder löschen
|
||||
// Tasks
|
||||
// Alle Referenzen auf den Benutzer behandeln
|
||||
// Tasks - assigned_to und created_by auf NULL setzen (erlaubt NULL)
|
||||
db.prepare('UPDATE tasks SET assigned_to = NULL WHERE assigned_to = ?').run(userId);
|
||||
db.prepare('UPDATE tasks SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
|
||||
// Kommentare
|
||||
db.prepare('UPDATE comments SET user_id = NULL WHERE user_id = ?').run(userId);
|
||||
// Task-Assignees löschen (Mehrfachzuweisung)
|
||||
db.prepare('DELETE FROM task_assignees WHERE user_id = ?').run(userId);
|
||||
|
||||
// Historie
|
||||
db.prepare('UPDATE history SET user_id = NULL WHERE user_id = ?').run(userId);
|
||||
// Kommentare löschen (user_id NOT NULL)
|
||||
db.prepare('DELETE FROM comments WHERE user_id = ?').run(userId);
|
||||
|
||||
// Vorschläge
|
||||
db.prepare('UPDATE proposals SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
// Historie löschen (user_id NOT NULL)
|
||||
db.prepare('DELETE FROM history WHERE user_id = ?').run(userId);
|
||||
|
||||
// Vorschläge: Votes zuerst löschen, dann Vorschläge des Benutzers
|
||||
db.prepare('DELETE FROM proposal_votes WHERE user_id = ?').run(userId);
|
||||
db.prepare('DELETE FROM proposals WHERE created_by = ?').run(userId);
|
||||
// approved_by kann NULL sein
|
||||
db.prepare('UPDATE proposals SET approved_by = NULL WHERE approved_by = ?').run(userId);
|
||||
|
||||
// Projekte
|
||||
// Projekte - created_by auf NULL setzen (erlaubt NULL)
|
||||
db.prepare('UPDATE projects SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
|
||||
// Anhänge
|
||||
// Anhänge - uploaded_by auf NULL setzen (erlaubt NULL)
|
||||
db.prepare('UPDATE attachments SET uploaded_by = NULL WHERE uploaded_by = ?').run(userId);
|
||||
|
||||
// Links
|
||||
// Links - created_by auf NULL setzen (erlaubt NULL)
|
||||
db.prepare('UPDATE links SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
|
||||
// Login-Audit (kann gelöscht werden)
|
||||
// Applications - created_by auf NULL setzen (erlaubt NULL)
|
||||
db.prepare('UPDATE applications SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
|
||||
// Login-Audit löschen
|
||||
db.prepare('DELETE FROM login_audit WHERE user_id = ?').run(userId);
|
||||
|
||||
// Votes des Benutzers löschen
|
||||
db.prepare('DELETE FROM proposal_votes WHERE user_id = ?').run(userId);
|
||||
// Benachrichtigungen löschen (wird auch durch CASCADE gelöscht, aber sicherheitshalber)
|
||||
db.prepare('DELETE FROM notifications WHERE user_id = ?').run(userId);
|
||||
db.prepare('UPDATE notifications SET actor_id = NULL WHERE actor_id = ?').run(userId);
|
||||
|
||||
// Benutzer löschen
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
@ -342,6 +339,17 @@ router.get('/upload-settings', (req, res) => {
|
||||
|
||||
if (setting) {
|
||||
const settings = JSON.parse(setting.value);
|
||||
|
||||
// Migration: Altes Format (allowedTypes) auf neues Format (allowedExtensions) umstellen
|
||||
if (settings.allowedTypes && !settings.allowedExtensions) {
|
||||
// Altes Format erkannt - auf Standard-Einstellungen zurücksetzen
|
||||
logger.info('Migriere Upload-Einstellungen auf neues Format (allowedExtensions)');
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('upload_settings', JSON.stringify(DEFAULT_UPLOAD_SETTINGS));
|
||||
res.json(DEFAULT_UPLOAD_SETTINGS);
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(settings);
|
||||
} else {
|
||||
// Standard-Einstellungen zurückgeben und speichern
|
||||
@ -360,24 +368,36 @@ router.get('/upload-settings', (req, res) => {
|
||||
*/
|
||||
router.put('/upload-settings', (req, res) => {
|
||||
try {
|
||||
const { maxFileSizeMB, allowedTypes } = req.body;
|
||||
const { maxFileSizeMB, allowedExtensions } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (typeof maxFileSizeMB !== 'number' || maxFileSizeMB < 1 || maxFileSizeMB > 100) {
|
||||
return res.status(400).json({ error: 'Maximale Dateigröße muss zwischen 1 und 100 MB liegen' });
|
||||
}
|
||||
|
||||
if (!allowedTypes || typeof allowedTypes !== 'object') {
|
||||
return res.status(400).json({ error: 'Ungültige Dateityp-Konfiguration' });
|
||||
if (!Array.isArray(allowedExtensions) || allowedExtensions.length === 0) {
|
||||
return res.status(400).json({ error: 'Mindestens eine Dateiendung muss erlaubt sein' });
|
||||
}
|
||||
|
||||
const settings = { maxFileSizeMB, allowedTypes };
|
||||
// Endungen validieren (nur alphanumerisch, 1-10 Zeichen)
|
||||
const validExtensions = allowedExtensions
|
||||
.map(ext => ext.toLowerCase().replace(/^\./, '')) // Punkt am Anfang entfernen
|
||||
.filter(ext => /^[a-z0-9]{1,10}$/.test(ext));
|
||||
|
||||
if (validExtensions.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine gültigen Dateiendungen angegeben' });
|
||||
}
|
||||
|
||||
// Duplikate entfernen
|
||||
const uniqueExtensions = [...new Set(validExtensions)];
|
||||
|
||||
const settings = { maxFileSizeMB, allowedExtensions: uniqueExtensions };
|
||||
|
||||
const db = getDb();
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('upload_settings', JSON.stringify(settings));
|
||||
|
||||
logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert`);
|
||||
logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert: ${uniqueExtensions.join(', ')}`);
|
||||
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
@ -395,7 +415,12 @@ function getUploadSettings() {
|
||||
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings');
|
||||
|
||||
if (setting) {
|
||||
return JSON.parse(setting.value);
|
||||
const settings = JSON.parse(setting.value);
|
||||
// Bei altem Format oder fehlendem allowedExtensions: Standard verwenden
|
||||
if (!settings.allowedExtensions || !Array.isArray(settings.allowedExtensions)) {
|
||||
return DEFAULT_UPLOAD_SETTINGS;
|
||||
}
|
||||
return settings;
|
||||
}
|
||||
return DEFAULT_UPLOAD_SETTINGS;
|
||||
} catch (error) {
|
||||
@ -404,6 +429,42 @@ function getUploadSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/admin/backup/create - Sofortiges verschlüsseltes Backup erstellen
|
||||
*/
|
||||
router.post('/backup/create', (req, res) => {
|
||||
try {
|
||||
const backupPath = backup.createBackup();
|
||||
|
||||
if (backupPath) {
|
||||
logger.info(`Admin ${req.user.username} hat manuelles Backup erstellt`);
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Verschlüsseltes Backup erfolgreich erstellt',
|
||||
backupPath: backupPath.split('/').pop() // Nur Dateiname zurückgeben
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Backup-Erstellung fehlgeschlagen' });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Backup-Erstellung durch Admin fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'Interner Fehler beim Erstellen des Backups' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/backup/list - Liste aller verschlüsselten Backups
|
||||
*/
|
||||
router.get('/backup/list', (req, res) => {
|
||||
try {
|
||||
const backups = backup.listBackups();
|
||||
res.json(backups);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Auflisten der Backups:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Auflisten der Backups' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
module.exports.getUploadSettings = getUploadSettings;
|
||||
module.exports.DEFAULT_UPLOAD_SETTINGS = DEFAULT_UPLOAD_SETTINGS;
|
||||
|
||||
@ -8,7 +8,7 @@ const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { getDb } = require('../database');
|
||||
const { generateToken, authenticateToken } = require('../middleware/auth');
|
||||
const { generateToken, generateRefreshToken, refreshAccessToken, revokeAllRefreshTokens, authenticateToken } = require('../middleware/auth');
|
||||
const { getTokenForUser } = require('../middleware/csrf');
|
||||
const { validatePassword } = require('../middleware/validation');
|
||||
const logger = require('../utils/logger');
|
||||
@ -35,13 +35,13 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Benutzer suchen: Zuerst nach Username "admin", dann nach E-Mail
|
||||
// Benutzer suchen: Admin kann mit "admin" einloggen, alle anderen mit E-Mail
|
||||
let user;
|
||||
if (username.toLowerCase() === 'admin') {
|
||||
// Admin-User per Username suchen
|
||||
user = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
|
||||
// Admin-Login über spezielle E-Mail
|
||||
user = db.prepare('SELECT * FROM users WHERE email = ? AND role = ?').get('admin@taskmate.local', 'admin');
|
||||
} else {
|
||||
// Normale User per E-Mail suchen
|
||||
// Normale User loggen sich mit E-Mail ein
|
||||
user = db.prepare('SELECT * FROM users WHERE email = ?').get(username);
|
||||
}
|
||||
|
||||
@ -111,8 +111,11 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
logAttempt(user.id, true);
|
||||
|
||||
// JWT-Token generieren
|
||||
const token = generateToken(user);
|
||||
// JWT Access-Token generieren (kurze Lebensdauer)
|
||||
const accessToken = generateToken(user);
|
||||
|
||||
// Refresh-Token generieren (lange Lebensdauer)
|
||||
const refreshToken = generateRefreshToken(user.id, ip, userAgent);
|
||||
|
||||
// CSRF-Token generieren
|
||||
const csrfToken = getTokenForUser(user.id);
|
||||
@ -128,11 +131,13 @@ router.post('/login', async (req, res) => {
|
||||
}
|
||||
|
||||
res.json({
|
||||
token,
|
||||
token: accessToken,
|
||||
refreshToken,
|
||||
csrfToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
initials: user.initials,
|
||||
displayName: user.display_name,
|
||||
color: user.color,
|
||||
role: user.role || 'user',
|
||||
@ -147,13 +152,19 @@ router.post('/login', async (req, res) => {
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Benutzer abmelden
|
||||
* Benutzer abmelden und Refresh-Tokens widerrufen
|
||||
*/
|
||||
router.post('/logout', authenticateToken, (req, res) => {
|
||||
// Bei JWT gibt es serverseitig nichts zu tun
|
||||
// Client muss Token löschen
|
||||
logger.info(`Logout: ${req.user.username}`);
|
||||
res.json({ message: 'Erfolgreich abgemeldet' });
|
||||
try {
|
||||
// Alle Refresh-Tokens des Benutzers löschen
|
||||
revokeAllRefreshTokens(req.user.id);
|
||||
|
||||
logger.info(`Logout: ${req.user.username}`);
|
||||
res.json({ message: 'Erfolgreich abgemeldet' });
|
||||
} catch (error) {
|
||||
logger.error('Logout-Fehler:', { error: error.message });
|
||||
res.status(500).json({ error: 'Logout fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
@ -200,27 +211,68 @@ router.get('/me', authenticateToken, (req, res) => {
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Token erneuern
|
||||
* Token mit Refresh-Token erneuern
|
||||
*/
|
||||
router.post('/refresh', authenticateToken, (req, res) => {
|
||||
router.post('/refresh', async (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
const { refreshToken } = req.body;
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
if (!refreshToken) {
|
||||
// Fallback für alte Clients - mit Access Token authentifizieren
|
||||
if (req.headers.authorization) {
|
||||
return legacyRefresh(req, res);
|
||||
}
|
||||
return res.status(400).json({ error: 'Refresh-Token erforderlich' });
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
const csrfToken = getTokenForUser(user.id);
|
||||
// Neuen Access-Token mit Refresh-Token generieren
|
||||
const accessToken = await refreshAccessToken(refreshToken, ip, userAgent);
|
||||
const db = getDb();
|
||||
|
||||
// User-Daten für CSRF-Token abrufen
|
||||
const decoded = require('jsonwebtoken').decode(accessToken);
|
||||
const csrfToken = getTokenForUser(decoded.id);
|
||||
|
||||
res.json({ token, csrfToken });
|
||||
res.json({
|
||||
token: accessToken,
|
||||
csrfToken
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Token-Refresh Fehler:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
res.status(401).json({ error: 'Token-Erneuerung fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
// Legacy Refresh für Rückwärtskompatibilität
|
||||
function legacyRefresh(req, res) {
|
||||
// Prüfe Authorization Header
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
const user = require('../middleware/auth').verifyToken(token);
|
||||
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Token ungültig' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const dbUser = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
|
||||
|
||||
if (!dbUser) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
|
||||
const newToken = generateToken(dbUser);
|
||||
const csrfToken = getTokenForUser(dbUser.id);
|
||||
|
||||
res.json({ token: newToken, csrfToken });
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/auth/password
|
||||
* Passwort ändern
|
||||
@ -299,14 +351,15 @@ router.get('/users', authenticateToken, (req, res) => {
|
||||
const db = getDb();
|
||||
// Nur regulaere Benutzer (nicht Admins) fuer Aufgaben-Zuweisung
|
||||
const users = db.prepare(`
|
||||
SELECT id, username, display_name, color
|
||||
SELECT id, email, initials, display_name, color
|
||||
FROM users
|
||||
WHERE role != 'admin' OR role IS NULL
|
||||
`).all();
|
||||
|
||||
res.json(users.map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
email: u.email,
|
||||
initials: u.initials,
|
||||
displayName: u.display_name,
|
||||
color: u.color
|
||||
})));
|
||||
|
||||
714
backend/routes/coding.js
Normale Datei
714
backend/routes/coding.js
Normale Datei
@ -0,0 +1,714 @@
|
||||
/**
|
||||
* TASKMATE - Coding Routes
|
||||
* ========================
|
||||
* Verwaltung von Server-Anwendungen mit Claude/Codex Integration
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const gitService = require('../services/gitService');
|
||||
|
||||
/**
|
||||
* Prüft ob ein Pfad ein Server-Pfad (Linux) ist
|
||||
*/
|
||||
function isServerPath(localPath) {
|
||||
return localPath && localPath.startsWith('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schreibt CLAUDE.md in ein Verzeichnis
|
||||
*/
|
||||
function writeCLAUDEmd(directoryPath, content) {
|
||||
if (!content || !directoryPath) return false;
|
||||
|
||||
try {
|
||||
const claudePath = path.join(directoryPath, 'CLAUDE.md');
|
||||
fs.writeFileSync(claudePath, content, 'utf8');
|
||||
logger.info(`CLAUDE.md geschrieben: ${claudePath}`);
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('CLAUDE.md schreiben fehlgeschlagen:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest CLAUDE.md aus einem Verzeichnis
|
||||
*/
|
||||
function readCLAUDEmd(directoryPath) {
|
||||
if (!directoryPath) {
|
||||
logger.info('readCLAUDEmd: No directoryPath provided');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const claudePath = path.join(directoryPath, 'CLAUDE.md');
|
||||
logger.info(`readCLAUDEmd: Checking path ${claudePath}`);
|
||||
|
||||
if (fs.existsSync(claudePath)) {
|
||||
const content = fs.readFileSync(claudePath, 'utf8');
|
||||
logger.info(`readCLAUDEmd: Successfully read ${content.length} characters from ${claudePath}`);
|
||||
return content;
|
||||
} else {
|
||||
logger.info(`readCLAUDEmd: File does not exist: ${claudePath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('CLAUDE.md lesen fehlgeschlagen:', e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories
|
||||
* Alle Coding-Verzeichnisse abrufen
|
||||
*/
|
||||
router.get('/directories', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const directories = db.prepare(`
|
||||
SELECT cd.*, u.display_name as creator_name
|
||||
FROM coding_directories cd
|
||||
LEFT JOIN users u ON cd.created_by = u.id
|
||||
ORDER BY cd.position ASC, cd.name ASC
|
||||
`).all();
|
||||
|
||||
res.json(directories.map(dir => {
|
||||
// CLAUDE.md aus dem Dateisystem lesen falls vorhanden
|
||||
let claudeMdFromDisk = null;
|
||||
if (isServerPath(dir.local_path)) {
|
||||
claudeMdFromDisk = readCLAUDEmd(dir.local_path);
|
||||
// Fallback: Wenn Pfad /home/claude-dev/TaskMate ist, versuche /app/taskmate-source
|
||||
if (!claudeMdFromDisk && dir.local_path === '/home/claude-dev/TaskMate') {
|
||||
logger.info('Trying fallback path for TaskMate: /app/taskmate-source');
|
||||
claudeMdFromDisk = readCLAUDEmd('/app/taskmate-source');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: dir.id,
|
||||
name: dir.name,
|
||||
localPath: dir.local_path,
|
||||
description: dir.description,
|
||||
color: dir.color,
|
||||
claudeInstructions: dir.claude_instructions,
|
||||
claudeMdFromDisk: claudeMdFromDisk,
|
||||
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||
giteaRepoUrl: dir.gitea_repo_url,
|
||||
giteaRepoOwner: dir.gitea_repo_owner,
|
||||
giteaRepoName: dir.gitea_repo_name,
|
||||
defaultBranch: dir.default_branch,
|
||||
lastSync: dir.last_sync,
|
||||
position: dir.position,
|
||||
createdAt: dir.created_at,
|
||||
createdBy: dir.created_by,
|
||||
creatorName: dir.creator_name
|
||||
};
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Coding-Verzeichnisse:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories
|
||||
* Neues Coding-Verzeichnis erstellen
|
||||
*/
|
||||
router.post('/directories', (req, res) => {
|
||||
try {
|
||||
const { name, localPath, description, color, claudeInstructions, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch } = req.body;
|
||||
|
||||
if (!name || !localPath) {
|
||||
return res.status(400).json({ error: 'Name und Server-Pfad sind erforderlich' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prüfe ob Pfad bereits existiert
|
||||
const existing = db.prepare('SELECT id FROM coding_directories WHERE local_path = ?').get(localPath);
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Diese Anwendung ist bereits registriert' });
|
||||
}
|
||||
|
||||
// Höchste Position ermitteln
|
||||
const maxPos = db.prepare('SELECT COALESCE(MAX(position), -1) as max FROM coding_directories').get().max;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO coding_directories (name, local_path, description, color, claude_instructions, gitea_repo_url, gitea_repo_owner, gitea_repo_name, default_branch, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
name,
|
||||
localPath,
|
||||
description || null,
|
||||
color || '#4F46E5',
|
||||
null, // claudeInstructions wird nicht mehr gespeichert
|
||||
giteaRepoUrl || null,
|
||||
giteaRepoOwner || null,
|
||||
giteaRepoName || null,
|
||||
defaultBranch || 'main',
|
||||
maxPos + 1,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
// Ordner erstellen falls nicht vorhanden
|
||||
let directoryCreated = false;
|
||||
if (isServerPath(localPath) && !fs.existsSync(localPath)) {
|
||||
try {
|
||||
fs.mkdirSync(localPath, { recursive: true });
|
||||
directoryCreated = true;
|
||||
logger.info(`Anwendungsordner erstellt: ${localPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Ordner erstellen fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// CLAUDE.md wird nicht mehr geschrieben - nur readonly
|
||||
let claudeMdWritten = false;
|
||||
|
||||
logger.info(`Coding-Anwendung erstellt: ${name} (${localPath})`);
|
||||
|
||||
// CLAUDE.md aus dem Dateisystem lesen für aktuelle Anzeige
|
||||
const claudeMdFromDisk = isServerPath(directory.local_path) ? readCLAUDEmd(directory.local_path) : null;
|
||||
|
||||
res.status(201).json({
|
||||
id: directory.id,
|
||||
name: directory.name,
|
||||
localPath: directory.local_path,
|
||||
description: directory.description,
|
||||
color: directory.color,
|
||||
claudeInstructions: directory.claude_instructions,
|
||||
claudeMdFromDisk: claudeMdFromDisk,
|
||||
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||
giteaRepoUrl: directory.gitea_repo_url,
|
||||
giteaRepoOwner: directory.gitea_repo_owner,
|
||||
giteaRepoName: directory.gitea_repo_name,
|
||||
defaultBranch: directory.default_branch,
|
||||
position: directory.position,
|
||||
createdAt: directory.created_at,
|
||||
directoryCreated,
|
||||
claudeMdWritten
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen der Coding-Anwendung:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/coding/directories/:id
|
||||
* Coding-Anwendung aktualisieren
|
||||
*/
|
||||
router.put('/directories/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { name, localPath, description, color, claudeInstructions, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch, position } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
// Prüfe ob neuer Pfad bereits von anderem Eintrag verwendet wird
|
||||
if (localPath && localPath !== existing.local_path) {
|
||||
const pathExists = db.prepare('SELECT id FROM coding_directories WHERE local_path = ? AND id != ?').get(localPath, id);
|
||||
if (pathExists) {
|
||||
return res.status(400).json({ error: 'Dieser Server-Pfad ist bereits registriert' });
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE coding_directories SET
|
||||
name = COALESCE(?, name),
|
||||
local_path = COALESCE(?, local_path),
|
||||
description = ?,
|
||||
color = COALESCE(?, color),
|
||||
claude_instructions = ?,
|
||||
gitea_repo_url = ?,
|
||||
gitea_repo_owner = ?,
|
||||
gitea_repo_name = ?,
|
||||
default_branch = COALESCE(?, default_branch),
|
||||
position = COALESCE(?, position)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name || null,
|
||||
localPath || null,
|
||||
description !== undefined ? description : existing.description,
|
||||
color || null,
|
||||
null, // claudeInstructions wird nicht mehr aktualisiert
|
||||
giteaRepoUrl !== undefined ? giteaRepoUrl : existing.gitea_repo_url,
|
||||
giteaRepoOwner !== undefined ? giteaRepoOwner : existing.gitea_repo_owner,
|
||||
giteaRepoName !== undefined ? giteaRepoName : existing.gitea_repo_name,
|
||||
defaultBranch || null,
|
||||
position !== undefined ? position : null,
|
||||
id
|
||||
);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
const finalPath = updated.local_path;
|
||||
|
||||
// Ordner erstellen falls nicht vorhanden
|
||||
let directoryCreated = false;
|
||||
if (isServerPath(finalPath) && !fs.existsSync(finalPath)) {
|
||||
try {
|
||||
fs.mkdirSync(finalPath, { recursive: true });
|
||||
directoryCreated = true;
|
||||
logger.info(`Anwendungsordner erstellt: ${finalPath}`);
|
||||
} catch (e) {
|
||||
logger.error('Ordner erstellen fehlgeschlagen:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// CLAUDE.md wird nicht mehr geschrieben - nur readonly
|
||||
let claudeMdWritten = false;
|
||||
|
||||
logger.info(`Coding-Anwendung aktualisiert: ${updated.name}`);
|
||||
|
||||
// CLAUDE.md aus dem Dateisystem lesen für aktuelle Anzeige
|
||||
const claudeMdFromDisk = isServerPath(updated.local_path) ? readCLAUDEmd(updated.local_path) : null;
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
name: updated.name,
|
||||
localPath: updated.local_path,
|
||||
description: updated.description,
|
||||
color: updated.color,
|
||||
claudeInstructions: updated.claude_instructions,
|
||||
claudeMdFromDisk: claudeMdFromDisk,
|
||||
hasCLAUDEmd: !!claudeMdFromDisk,
|
||||
giteaRepoUrl: updated.gitea_repo_url,
|
||||
giteaRepoOwner: updated.gitea_repo_owner,
|
||||
giteaRepoName: updated.gitea_repo_name,
|
||||
defaultBranch: updated.default_branch,
|
||||
position: updated.position,
|
||||
createdAt: updated.created_at,
|
||||
directoryCreated,
|
||||
claudeMdWritten
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren der Coding-Anwendung:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/coding/directories/:id
|
||||
* Coding-Anwendung löschen
|
||||
*/
|
||||
router.delete('/directories/:id', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM coding_directories WHERE id = ?').run(id);
|
||||
|
||||
logger.info(`Coding-Anwendung gelöscht: ${existing.name}`);
|
||||
|
||||
res.json({ message: 'Anwendung gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen der Coding-Anwendung:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories/:id/status
|
||||
* Git-Status eines Verzeichnisses abrufen
|
||||
*/
|
||||
router.get('/directories/:id/status', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const localPath = directory.local_path;
|
||||
|
||||
// Prüfe ob es ein Git-Repository ist
|
||||
if (!gitService.isGitRepository(localPath)) {
|
||||
return res.json({
|
||||
isGitRepo: false,
|
||||
message: 'Kein Git-Repository'
|
||||
});
|
||||
}
|
||||
|
||||
const status = gitService.getStatus(localPath);
|
||||
|
||||
if (!status.success) {
|
||||
return res.status(500).json({ error: status.error });
|
||||
}
|
||||
|
||||
res.json({
|
||||
isGitRepo: true,
|
||||
branch: status.branch,
|
||||
hasChanges: status.hasChanges,
|
||||
changes: status.changes,
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
isClean: status.isClean
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des Git-Status:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/fetch
|
||||
* Git Fetch ausführen
|
||||
*/
|
||||
router.post('/directories/:id/fetch', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.fetchRemote(directory.local_path);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
// Last sync aktualisieren
|
||||
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
|
||||
logger.info(`Git fetch ausgeführt für: ${directory.name}`);
|
||||
|
||||
res.json({ success: true, message: 'Fetch erfolgreich' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Git Fetch:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/pull
|
||||
* Git Pull ausführen
|
||||
*/
|
||||
router.post('/directories/:id/pull', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.pullChanges(directory.local_path, { branch: directory.default_branch });
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
// Last sync aktualisieren
|
||||
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
|
||||
logger.info(`Git pull ausgeführt für: ${directory.name}`);
|
||||
|
||||
res.json({ success: true, message: 'Pull erfolgreich', output: result.output });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Git Pull:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/push
|
||||
* Git Push ausführen
|
||||
*/
|
||||
router.post('/directories/:id/push', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { force } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.pushWithUpstream(directory.local_path, directory.default_branch, 'origin', force);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
// Last sync aktualisieren
|
||||
db.prepare('UPDATE coding_directories SET last_sync = CURRENT_TIMESTAMP WHERE id = ?').run(id);
|
||||
|
||||
logger.info(`Git push ausgeführt für: ${directory.name}`);
|
||||
|
||||
res.json({ success: true, message: 'Push erfolgreich', output: result.output });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Git Push:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/commit
|
||||
* Git Commit ausführen
|
||||
*/
|
||||
router.post('/directories/:id/commit', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { message } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
if (!message || message.trim() === '') {
|
||||
return res.status(400).json({ error: 'Commit-Nachricht erforderlich' });
|
||||
}
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
// Stage all changes
|
||||
const stageResult = gitService.stageAll(directory.local_path);
|
||||
if (!stageResult.success) {
|
||||
return res.status(500).json({ error: stageResult.error });
|
||||
}
|
||||
|
||||
// Commit with author info
|
||||
const author = {
|
||||
name: req.user.display_name || req.user.username,
|
||||
email: req.user.email || `${req.user.username}@taskmate.local`
|
||||
};
|
||||
|
||||
const result = gitService.commit(directory.local_path, message, author);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
logger.info(`Git commit ausgeführt für: ${directory.name} - "${message}"`);
|
||||
|
||||
res.json({ success: true, message: 'Commit erfolgreich', output: result.output });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Git Commit:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories/:id/branches
|
||||
* Branches abrufen
|
||||
*/
|
||||
router.get('/directories/:id/branches', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.getBranches(directory.local_path);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ branches: result.branches });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Branches:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/directories/:id/checkout
|
||||
* Branch wechseln
|
||||
*/
|
||||
router.post('/directories/:id/checkout', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { branch } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
if (!branch) {
|
||||
return res.status(400).json({ error: 'Branch erforderlich' });
|
||||
}
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.checkoutBranch(directory.local_path, branch);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
logger.info(`Branch gewechselt für ${directory.name}: ${branch}`);
|
||||
|
||||
res.json({ success: true, message: `Gewechselt zu Branch: ${branch}` });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Branch-Wechsel:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/coding/validate-path
|
||||
* Pfad validieren
|
||||
*/
|
||||
router.post('/validate-path', (req, res) => {
|
||||
try {
|
||||
const { path: localPath } = req.body;
|
||||
|
||||
if (!localPath) {
|
||||
return res.status(400).json({ error: 'Pfad erforderlich' });
|
||||
}
|
||||
|
||||
// Nur Server-Pfade können validiert werden
|
||||
if (isServerPath(localPath)) {
|
||||
const containerPath = gitService.windowsToContainerPath(localPath);
|
||||
const exists = fs.existsSync(containerPath);
|
||||
const isGitRepo = exists && gitService.isGitRepository(localPath);
|
||||
|
||||
res.json({
|
||||
valid: true,
|
||||
exists,
|
||||
isGitRepo,
|
||||
isServerPath: true
|
||||
});
|
||||
} else {
|
||||
// Windows-Pfad kann nicht serverseitig validiert werden
|
||||
res.json({
|
||||
valid: true,
|
||||
exists: null,
|
||||
isGitRepo: null,
|
||||
isServerPath: false,
|
||||
message: 'Windows-Pfade können nicht serverseitig validiert werden'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei der Pfad-Validierung:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories/:id/commits
|
||||
* Commit-Historie abrufen
|
||||
*/
|
||||
router.get('/directories/:id/commits', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const result = gitService.getCommitHistory(directory.local_path, limit);
|
||||
|
||||
if (!result.success) {
|
||||
return res.status(500).json({ error: result.error });
|
||||
}
|
||||
|
||||
res.json({ commits: result.commits });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Commit-Historie:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories/:id/usage
|
||||
* Aktuelle Verbrauchsdaten abrufen (simuliert)
|
||||
*/
|
||||
router.get('/directories/:id/usage', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
// Simulierte Verbrauchsdaten generieren
|
||||
const usage = {
|
||||
cpu_percent: Math.random() * 100,
|
||||
memory_mb: Math.floor(Math.random() * 4096),
|
||||
disk_read_mb: Math.random() * 100,
|
||||
disk_write_mb: Math.random() * 50,
|
||||
network_recv_mb: Math.random() * 10,
|
||||
network_sent_mb: Math.random() * 10,
|
||||
process_count: Math.floor(Math.random() * 20) + 1,
|
||||
timestamp: new Date()
|
||||
};
|
||||
|
||||
// Speichere in Datenbank für Historie
|
||||
db.prepare(`
|
||||
INSERT INTO coding_usage (directory_id, cpu_percent, memory_mb, disk_read_mb,
|
||||
disk_write_mb, network_recv_mb, network_sent_mb, process_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, usage.cpu_percent, usage.memory_mb, usage.disk_read_mb,
|
||||
usage.disk_write_mb, usage.network_recv_mb, usage.network_sent_mb, usage.process_count);
|
||||
|
||||
res.json({ usage });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Verbrauchsdaten:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/coding/directories/:id/usage/history
|
||||
* Historische Verbrauchsdaten abrufen
|
||||
*/
|
||||
router.get('/directories/:id/usage/history', (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const hours = parseInt(req.query.hours) || 24;
|
||||
const db = getDb();
|
||||
|
||||
const directory = db.prepare('SELECT * FROM coding_directories WHERE id = ?').get(id);
|
||||
if (!directory) {
|
||||
return res.status(404).json({ error: 'Anwendung nicht gefunden' });
|
||||
}
|
||||
|
||||
const history = db.prepare(`
|
||||
SELECT * FROM coding_usage
|
||||
WHERE directory_id = ?
|
||||
AND timestamp > datetime('now', '-${hours} hours')
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 100
|
||||
`).all(id);
|
||||
|
||||
res.json({ history });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Verbrauchshistorie:', error);
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
439
backend/routes/contacts.js
Normale Datei
439
backend/routes/contacts.js
Normale Datei
@ -0,0 +1,439 @@
|
||||
/**
|
||||
* TASKMATE - Contact Routes
|
||||
* =========================
|
||||
* CRUD für Kontakte
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators } = require('../middleware/validation');
|
||||
|
||||
/**
|
||||
* GET /api/contacts
|
||||
* Alle Kontakte abrufen mit optionalem Filter
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const { search, tag, sortBy = 'created_at', sortOrder = 'desc' } = req.query;
|
||||
|
||||
let query = `
|
||||
SELECT c.*, u.display_name as creator_name
|
||||
FROM contacts c
|
||||
LEFT JOIN users u ON c.created_by = u.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
// Suchfilter
|
||||
if (search) {
|
||||
query += ` AND (
|
||||
c.first_name LIKE ? OR
|
||||
c.last_name LIKE ? OR
|
||||
c.company LIKE ? OR
|
||||
c.email LIKE ? OR
|
||||
c.phone LIKE ? OR
|
||||
c.mobile LIKE ?
|
||||
)`;
|
||||
const searchParam = `%${search}%`;
|
||||
params.push(searchParam, searchParam, searchParam, searchParam, searchParam, searchParam);
|
||||
}
|
||||
|
||||
// Tag-Filter
|
||||
if (tag) {
|
||||
query += ` AND c.tags LIKE ?`;
|
||||
params.push(`%${tag}%`);
|
||||
}
|
||||
|
||||
// Sortierung
|
||||
const validSortFields = ['first_name', 'last_name', 'company', 'created_at', 'updated_at'];
|
||||
const sortField = validSortFields.includes(sortBy) ? sortBy : 'created_at';
|
||||
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
query += ` ORDER BY c.${sortField} ${order}`;
|
||||
|
||||
const contacts = db.prepare(query).all(params);
|
||||
|
||||
res.json(contacts.map(c => ({
|
||||
id: c.id,
|
||||
firstName: c.first_name,
|
||||
lastName: c.last_name,
|
||||
company: c.company,
|
||||
position: c.position,
|
||||
email: c.email,
|
||||
phone: c.phone,
|
||||
mobile: c.mobile,
|
||||
address: c.address,
|
||||
postalCode: c.postal_code,
|
||||
city: c.city,
|
||||
country: c.country,
|
||||
website: c.website,
|
||||
notes: c.notes,
|
||||
tags: c.tags ? c.tags.split(',').map(t => t.trim()) : [],
|
||||
createdAt: c.created_at,
|
||||
updatedAt: c.updated_at,
|
||||
createdBy: c.created_by,
|
||||
creatorName: c.creator_name
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Kontakte:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/contacts/:id
|
||||
* Einzelnen Kontakt abrufen
|
||||
*/
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const contactId = req.params.id;
|
||||
|
||||
const contact = db.prepare(`
|
||||
SELECT c.*, u.display_name as creator_name
|
||||
FROM contacts c
|
||||
LEFT JOIN users u ON c.created_by = u.id
|
||||
WHERE c.id = ?
|
||||
`).get(contactId);
|
||||
|
||||
if (!contact) {
|
||||
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
id: contact.id,
|
||||
firstName: contact.first_name,
|
||||
lastName: contact.last_name,
|
||||
company: contact.company,
|
||||
position: contact.position,
|
||||
email: contact.email,
|
||||
phone: contact.phone,
|
||||
mobile: contact.mobile,
|
||||
address: contact.address,
|
||||
postalCode: contact.postal_code,
|
||||
city: contact.city,
|
||||
country: contact.country,
|
||||
website: contact.website,
|
||||
notes: contact.notes,
|
||||
tags: contact.tags ? contact.tags.split(',').map(t => t.trim()) : [],
|
||||
createdAt: contact.created_at,
|
||||
updatedAt: contact.updated_at,
|
||||
createdBy: contact.created_by,
|
||||
creatorName: contact.creator_name
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des Kontakts:', { error: error.message, contactId: req.params.id });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/contacts
|
||||
* Neuen Kontakt erstellen
|
||||
*/
|
||||
router.post('/', validators.contact, (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const userId = req.user.id;
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
position,
|
||||
email,
|
||||
phone,
|
||||
mobile,
|
||||
address,
|
||||
postalCode,
|
||||
city,
|
||||
country,
|
||||
website,
|
||||
notes,
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO contacts (
|
||||
first_name, last_name, company, position,
|
||||
email, phone, mobile, address, postal_code,
|
||||
city, country, website, notes, tags,
|
||||
created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
firstName || null,
|
||||
lastName || null,
|
||||
company || null,
|
||||
position || null,
|
||||
email || null,
|
||||
phone || null,
|
||||
mobile || null,
|
||||
address || null,
|
||||
postalCode || null,
|
||||
city || null,
|
||||
country || null,
|
||||
website || null,
|
||||
notes || null,
|
||||
Array.isArray(tags) ? tags.join(', ') : null,
|
||||
userId
|
||||
);
|
||||
|
||||
const newContact = db.prepare(`
|
||||
SELECT c.*, u.display_name as creator_name
|
||||
FROM contacts c
|
||||
LEFT JOIN users u ON c.created_by = u.id
|
||||
WHERE c.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
// Socket.io Event
|
||||
const io = req.app.get('io');
|
||||
io.emit('contact:created', {
|
||||
contact: {
|
||||
id: newContact.id,
|
||||
firstName: newContact.first_name,
|
||||
lastName: newContact.last_name,
|
||||
company: newContact.company,
|
||||
position: newContact.position,
|
||||
email: newContact.email,
|
||||
phone: newContact.phone,
|
||||
mobile: newContact.mobile,
|
||||
address: newContact.address,
|
||||
postalCode: newContact.postal_code,
|
||||
city: newContact.city,
|
||||
country: newContact.country,
|
||||
website: newContact.website,
|
||||
notes: newContact.notes,
|
||||
tags: newContact.tags ? newContact.tags.split(',').map(t => t.trim()) : [],
|
||||
createdAt: newContact.created_at,
|
||||
updatedAt: newContact.updated_at,
|
||||
createdBy: newContact.created_by,
|
||||
creatorName: newContact.creator_name
|
||||
},
|
||||
userId
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: newContact.id,
|
||||
firstName: newContact.first_name,
|
||||
lastName: newContact.last_name,
|
||||
company: newContact.company,
|
||||
position: newContact.position,
|
||||
email: newContact.email,
|
||||
phone: newContact.phone,
|
||||
mobile: newContact.mobile,
|
||||
address: newContact.address,
|
||||
postalCode: newContact.postal_code,
|
||||
city: newContact.city,
|
||||
country: newContact.country,
|
||||
website: newContact.website,
|
||||
notes: newContact.notes,
|
||||
tags: newContact.tags ? newContact.tags.split(',').map(t => t.trim()) : [],
|
||||
createdAt: newContact.created_at,
|
||||
updatedAt: newContact.updated_at,
|
||||
createdBy: newContact.created_by,
|
||||
creatorName: newContact.creator_name
|
||||
});
|
||||
|
||||
logger.info('Kontakt erstellt', { contactId: newContact.id, userId });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Kontakts:', { error: error.message, body: req.body });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/contacts/:id
|
||||
* Kontakt aktualisieren
|
||||
*/
|
||||
router.put('/:id', validators.contact, (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const contactId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
const {
|
||||
firstName,
|
||||
lastName,
|
||||
company,
|
||||
position,
|
||||
email,
|
||||
phone,
|
||||
mobile,
|
||||
address,
|
||||
postalCode,
|
||||
city,
|
||||
country,
|
||||
website,
|
||||
notes,
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
// Prüfen ob Kontakt existiert
|
||||
const existing = db.prepare('SELECT id FROM contacts WHERE id = ?').get(contactId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
|
||||
}
|
||||
|
||||
// Update
|
||||
db.prepare(`
|
||||
UPDATE contacts SET
|
||||
first_name = ?,
|
||||
last_name = ?,
|
||||
company = ?,
|
||||
position = ?,
|
||||
email = ?,
|
||||
phone = ?,
|
||||
mobile = ?,
|
||||
address = ?,
|
||||
postal_code = ?,
|
||||
city = ?,
|
||||
country = ?,
|
||||
website = ?,
|
||||
notes = ?,
|
||||
tags = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
firstName || null,
|
||||
lastName || null,
|
||||
company || null,
|
||||
position || null,
|
||||
email || null,
|
||||
phone || null,
|
||||
mobile || null,
|
||||
address || null,
|
||||
postalCode || null,
|
||||
city || null,
|
||||
country || null,
|
||||
website || null,
|
||||
notes || null,
|
||||
Array.isArray(tags) ? tags.join(', ') : null,
|
||||
contactId
|
||||
);
|
||||
|
||||
const updatedContact = db.prepare(`
|
||||
SELECT c.*, u.display_name as creator_name
|
||||
FROM contacts c
|
||||
LEFT JOIN users u ON c.created_by = u.id
|
||||
WHERE c.id = ?
|
||||
`).get(contactId);
|
||||
|
||||
// Socket.io Event
|
||||
const io = req.app.get('io');
|
||||
io.emit('contact:updated', {
|
||||
contact: {
|
||||
id: updatedContact.id,
|
||||
firstName: updatedContact.first_name,
|
||||
lastName: updatedContact.last_name,
|
||||
company: updatedContact.company,
|
||||
position: updatedContact.position,
|
||||
email: updatedContact.email,
|
||||
phone: updatedContact.phone,
|
||||
mobile: updatedContact.mobile,
|
||||
address: updatedContact.address,
|
||||
postalCode: updatedContact.postal_code,
|
||||
city: updatedContact.city,
|
||||
country: updatedContact.country,
|
||||
website: updatedContact.website,
|
||||
notes: updatedContact.notes,
|
||||
tags: updatedContact.tags ? updatedContact.tags.split(',').map(t => t.trim()) : [],
|
||||
createdAt: updatedContact.created_at,
|
||||
updatedAt: updatedContact.updated_at,
|
||||
createdBy: updatedContact.created_by,
|
||||
creatorName: updatedContact.creator_name
|
||||
},
|
||||
userId
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: updatedContact.id,
|
||||
firstName: updatedContact.first_name,
|
||||
lastName: updatedContact.last_name,
|
||||
company: updatedContact.company,
|
||||
position: updatedContact.position,
|
||||
email: updatedContact.email,
|
||||
phone: updatedContact.phone,
|
||||
mobile: updatedContact.mobile,
|
||||
address: updatedContact.address,
|
||||
postalCode: updatedContact.postal_code,
|
||||
city: updatedContact.city,
|
||||
country: updatedContact.country,
|
||||
website: updatedContact.website,
|
||||
notes: updatedContact.notes,
|
||||
tags: updatedContact.tags ? updatedContact.tags.split(',').map(t => t.trim()) : [],
|
||||
createdAt: updatedContact.created_at,
|
||||
updatedAt: updatedContact.updated_at,
|
||||
createdBy: updatedContact.created_by,
|
||||
creatorName: updatedContact.creator_name
|
||||
});
|
||||
|
||||
logger.info('Kontakt aktualisiert', { contactId, userId });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren des Kontakts:', { error: error.message, contactId: req.params.id });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/contacts/:id
|
||||
* Kontakt löschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const contactId = req.params.id;
|
||||
const userId = req.user.id;
|
||||
|
||||
// Prüfen ob Kontakt existiert
|
||||
const existing = db.prepare('SELECT id FROM contacts WHERE id = ?').get(contactId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Kontakt nicht gefunden' });
|
||||
}
|
||||
|
||||
// Löschen
|
||||
db.prepare('DELETE FROM contacts WHERE id = ?').run(contactId);
|
||||
|
||||
// Socket.io Event
|
||||
const io = req.app.get('io');
|
||||
io.emit('contact:deleted', { contactId, userId });
|
||||
|
||||
res.json({ success: true });
|
||||
|
||||
logger.info('Kontakt gelöscht', { contactId, userId });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Kontakts:', { error: error.message, contactId: req.params.id });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/contacts/tags
|
||||
* Alle verwendeten Tags abrufen
|
||||
*/
|
||||
router.get('/tags/all', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
const contacts = db.prepare('SELECT DISTINCT tags FROM contacts WHERE tags IS NOT NULL').all();
|
||||
|
||||
// Alle Tags sammeln und deduplizieren
|
||||
const allTags = new Set();
|
||||
contacts.forEach(contact => {
|
||||
if (contact.tags) {
|
||||
contact.tags.split(',').forEach(tag => {
|
||||
const trimmedTag = tag.trim();
|
||||
if (trimmedTag) {
|
||||
allTags.add(trimmedTag);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
res.json(Array.from(allTags).sort());
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Tags:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -6,10 +6,48 @@
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const gitService = require('../services/gitService');
|
||||
const giteaService = require('../services/giteaService');
|
||||
const multer = require('multer');
|
||||
|
||||
// Fester Pfad für Server-Modus (TaskMate Source-Code)
|
||||
const SERVER_SOURCE_PATH = '/app/taskmate-source';
|
||||
|
||||
// Temporäres Verzeichnis für Browser-Uploads
|
||||
const TEMP_UPLOAD_DIR = path.join(os.tmpdir(), 'taskmate-git-uploads');
|
||||
|
||||
// Multer-Konfiguration für Git-Uploads (beliebige Dateitypen)
|
||||
const gitUploadStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
// Erstelle eindeutiges temporäres Verzeichnis pro Upload-Session
|
||||
const sessionId = req.body.sessionId || Date.now().toString();
|
||||
const sessionDir = path.join(TEMP_UPLOAD_DIR, sessionId);
|
||||
|
||||
// Relativen Pfad aus dem Dateinamen extrahieren (wird vom Frontend gesendet)
|
||||
const relativePath = file.originalname;
|
||||
const fileDir = path.join(sessionDir, path.dirname(relativePath));
|
||||
|
||||
fs.mkdirSync(fileDir, { recursive: true });
|
||||
cb(null, fileDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Nur den Dateinamen ohne Pfad
|
||||
cb(null, path.basename(file.originalname));
|
||||
}
|
||||
});
|
||||
|
||||
const gitUpload = multer({
|
||||
storage: gitUploadStorage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024, // 50MB pro Datei
|
||||
files: 500 // Maximal 500 Dateien
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: Anwendung für Projekt abrufen
|
||||
@ -175,8 +213,14 @@ router.post('/commit/:projectId', (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Commit erstellen
|
||||
const result = gitService.commit(application.local_path, message);
|
||||
// Autor aus eingeloggtem Benutzer
|
||||
const author = req.user ? {
|
||||
name: req.user.display_name || req.user.username,
|
||||
email: req.user.email || `${req.user.username.toLowerCase()}@taskmate.local`
|
||||
} : null;
|
||||
|
||||
// Commit erstellen mit Autor
|
||||
const result = gitService.commit(application.local_path, message, author);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Commit:', error);
|
||||
@ -414,24 +458,58 @@ router.post('/set-remote/:projectId', (req, res) => {
|
||||
/**
|
||||
* POST /api/git/init-push/:projectId
|
||||
* Initialen Push mit Upstream-Tracking
|
||||
* Body: { targetBranch?: string, force?: boolean }
|
||||
* - targetBranch: Optional - Ziel-Branch auf Remote (z.B. "main" auch wenn lokal "master")
|
||||
* - force: Optional - Force-Push um Remote zu überschreiben
|
||||
*/
|
||||
router.post('/init-push/:projectId', (req, res) => {
|
||||
router.post('/init-push/:projectId', async (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { branch } = req.body;
|
||||
const { targetBranch, force } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
const currentBranch = branch || 'main';
|
||||
const result = gitService.pushWithUpstream(application.local_path, currentBranch);
|
||||
// targetBranch kann null sein - dann wird der lokale Branch-Name verwendet
|
||||
// force: boolean - überschreibt Remote-Branch bei Konflikten
|
||||
const result = gitService.pushWithUpstream(application.local_path, targetBranch || null, 'origin', force === true);
|
||||
|
||||
if (result.success) {
|
||||
// Sync-Zeitpunkt aktualisieren
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
|
||||
|
||||
// Default-Branch in Gitea aktualisieren wenn der gepushte Branch abweicht
|
||||
if (application.gitea_repo_url && result.branch) {
|
||||
try {
|
||||
// Owner/Repo aus URL extrahieren (z.B. https://gitea.../AegisSight/TaskMate.git)
|
||||
const urlMatch = application.gitea_repo_url.match(/\/([^\/]+)\/([^\/]+?)(?:\.git)?$/);
|
||||
if (urlMatch) {
|
||||
const owner = urlMatch[1];
|
||||
const repoName = urlMatch[2];
|
||||
const actualBranch = result.branch;
|
||||
|
||||
// Default-Branch in Gitea setzen
|
||||
const updateResult = await giteaService.updateRepository(owner, repoName, {
|
||||
defaultBranch: actualBranch
|
||||
});
|
||||
|
||||
if (updateResult.success) {
|
||||
logger.info(`Default-Branch in Gitea auf '${actualBranch}' gesetzt für ${owner}/${repoName}`);
|
||||
result.giteaUpdated = true;
|
||||
result.defaultBranch = actualBranch;
|
||||
} else {
|
||||
logger.warn(`Konnte Default-Branch in Gitea nicht aktualisieren: ${updateResult.error}`);
|
||||
result.giteaUpdated = false;
|
||||
}
|
||||
}
|
||||
} catch (giteaError) {
|
||||
logger.warn('Fehler beim Aktualisieren des Default-Branch in Gitea:', giteaError);
|
||||
result.giteaUpdated = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
@ -441,4 +519,451 @@ router.post('/init-push/:projectId', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/rename-branch/:projectId
|
||||
* Branch umbenennen
|
||||
* Body: { oldName: string, newName: string }
|
||||
*/
|
||||
router.post('/rename-branch/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { oldName, newName } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
if (!oldName || !newName) {
|
||||
return res.status(400).json({ error: 'oldName und newName sind erforderlich' });
|
||||
}
|
||||
|
||||
const result = gitService.renameBranch(application.local_path, oldName, newName);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Umbenennen des Branches:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// SERVER-MODUS ENDPOINTS
|
||||
// Für direkte Git-Operationen auf dem TaskMate Source-Code
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* GET /api/git/server/status
|
||||
* Git-Status für Server-Dateien abrufen
|
||||
*/
|
||||
router.get('/server/status', (req, res) => {
|
||||
try {
|
||||
// Prüfe ob der Pfad existiert und ein Git-Repo ist
|
||||
if (!gitService.isPathAccessible(SERVER_SOURCE_PATH)) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Server-Verzeichnis nicht erreichbar'
|
||||
});
|
||||
}
|
||||
|
||||
if (!gitService.isGitRepository(SERVER_SOURCE_PATH)) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Server-Verzeichnis ist kein Git-Repository'
|
||||
});
|
||||
}
|
||||
|
||||
const result = gitService.getStatus(SERVER_SOURCE_PATH);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Status:', error);
|
||||
res.status(500).json({ error: 'Serverfehler', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/server/branches
|
||||
* Branches für Server-Dateien abrufen
|
||||
*/
|
||||
router.get('/server/branches', (req, res) => {
|
||||
try {
|
||||
const result = gitService.getBranches(SERVER_SOURCE_PATH);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Abrufen der Branches:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/server/commits
|
||||
* Commit-Historie für Server-Dateien abrufen
|
||||
*/
|
||||
router.get('/server/commits', (req, res) => {
|
||||
try {
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const result = gitService.getCommitHistory(SERVER_SOURCE_PATH, limit);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Abrufen der Commits:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/server/remote
|
||||
* Remote-URL für Server-Dateien abrufen
|
||||
*/
|
||||
router.get('/server/remote', (req, res) => {
|
||||
try {
|
||||
const result = gitService.getRemoteUrl(SERVER_SOURCE_PATH);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Abrufen der Remote-URL:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/server/stage
|
||||
* Alle Änderungen für Server-Dateien stagen
|
||||
*/
|
||||
router.post('/server/stage', (req, res) => {
|
||||
try {
|
||||
const result = gitService.stageAll(SERVER_SOURCE_PATH);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Stagen:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/server/commit
|
||||
* Commit für Server-Dateien erstellen
|
||||
*/
|
||||
router.post('/server/commit', (req, res) => {
|
||||
try {
|
||||
const { message, stageAll } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: 'Commit-Nachricht ist erforderlich' });
|
||||
}
|
||||
|
||||
// Optional: Alle Änderungen stagen
|
||||
if (stageAll !== false) {
|
||||
const stageResult = gitService.stageAll(SERVER_SOURCE_PATH);
|
||||
if (!stageResult.success) {
|
||||
return res.json(stageResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Autor aus eingeloggtem Benutzer
|
||||
const author = req.user ? {
|
||||
name: req.user.display_name || req.user.username,
|
||||
email: req.user.email || `${req.user.username.toLowerCase()}@taskmate.local`
|
||||
} : null;
|
||||
|
||||
const result = gitService.commit(SERVER_SOURCE_PATH, message, author);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Commit:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/server/push
|
||||
* Push für Server-Dateien ausführen
|
||||
*/
|
||||
router.post('/server/push', (req, res) => {
|
||||
try {
|
||||
const { branch, force } = req.body;
|
||||
|
||||
// Prüfe ob Remote existiert
|
||||
if (!gitService.hasRemote(SERVER_SOURCE_PATH)) {
|
||||
return res.json({
|
||||
success: false,
|
||||
error: 'Kein Remote konfiguriert'
|
||||
});
|
||||
}
|
||||
|
||||
let result;
|
||||
if (force) {
|
||||
// Force Push
|
||||
result = gitService.pushWithUpstream(SERVER_SOURCE_PATH, branch || null, 'origin', true);
|
||||
} else {
|
||||
// Normaler Push
|
||||
result = gitService.pushChanges(SERVER_SOURCE_PATH, { branch });
|
||||
|
||||
// Falls Push wegen fehlendem Upstream fehlschlägt, versuche mit -u
|
||||
if (!result.success && result.error && result.error.includes('no upstream')) {
|
||||
result = gitService.pushWithUpstream(SERVER_SOURCE_PATH, branch || null);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Push:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/server/pull
|
||||
* Pull für Server-Dateien ausführen
|
||||
*/
|
||||
router.post('/server/pull', (req, res) => {
|
||||
try {
|
||||
const { branch } = req.body;
|
||||
|
||||
// Fetch zuerst
|
||||
gitService.fetchRemote(SERVER_SOURCE_PATH);
|
||||
|
||||
// Dann Pull
|
||||
const result = gitService.pullChanges(SERVER_SOURCE_PATH, { branch });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Pull:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/server/fetch
|
||||
* Fetch für Server-Dateien ausführen
|
||||
*/
|
||||
router.post('/server/fetch', (req, res) => {
|
||||
try {
|
||||
const result = gitService.fetchRemote(SERVER_SOURCE_PATH);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Fetch:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/server/checkout
|
||||
* Branch für Server-Dateien wechseln
|
||||
*/
|
||||
router.post('/server/checkout', (req, res) => {
|
||||
try {
|
||||
const { branch } = req.body;
|
||||
|
||||
if (!branch) {
|
||||
return res.status(400).json({ error: 'Branch ist erforderlich' });
|
||||
}
|
||||
|
||||
const result = gitService.checkoutBranch(SERVER_SOURCE_PATH, branch);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Branch-Wechsel:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/server/info
|
||||
* Grundlegende Infos über Server-Repository
|
||||
*/
|
||||
router.get('/server/info', (req, res) => {
|
||||
try {
|
||||
const isAccessible = gitService.isPathAccessible(SERVER_SOURCE_PATH);
|
||||
const isRepo = isAccessible ? gitService.isGitRepository(SERVER_SOURCE_PATH) : false;
|
||||
const hasRemote = isRepo ? gitService.hasRemote(SERVER_SOURCE_PATH) : false;
|
||||
|
||||
let remoteUrl = null;
|
||||
if (hasRemote) {
|
||||
const remoteResult = gitService.getRemoteUrl(SERVER_SOURCE_PATH);
|
||||
remoteUrl = remoteResult.url || null;
|
||||
}
|
||||
|
||||
res.json({
|
||||
path: SERVER_SOURCE_PATH,
|
||||
hostPath: '/home/claude-dev/TaskMate',
|
||||
accessible: isAccessible,
|
||||
isRepository: isRepo,
|
||||
hasRemote: hasRemote,
|
||||
remoteUrl: remoteUrl
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[Server-Git] Fehler beim Info-Abruf:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// BROWSER-UPLOAD ENDPOINTS
|
||||
// Für lokale Verzeichnis-Uploads vom Browser
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: Verzeichnis rekursiv löschen
|
||||
*/
|
||||
function deleteFolderRecursive(dirPath) {
|
||||
if (fs.existsSync(dirPath)) {
|
||||
fs.readdirSync(dirPath).forEach((file) => {
|
||||
const curPath = path.join(dirPath, file);
|
||||
if (fs.lstatSync(curPath).isDirectory()) {
|
||||
deleteFolderRecursive(curPath);
|
||||
} else {
|
||||
fs.unlinkSync(curPath);
|
||||
}
|
||||
});
|
||||
fs.rmdirSync(dirPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/git/browser-upload
|
||||
* Empfängt Dateien vom Browser und pusht sie ins Gitea
|
||||
*
|
||||
* Body (multipart/form-data):
|
||||
* - files: Die hochgeladenen Dateien (originalname enthält relativen Pfad)
|
||||
* - repoUrl: Die Gitea-Repository-URL
|
||||
* - branch: Der Ziel-Branch (default: main)
|
||||
* - commitMessage: Die Commit-Nachricht
|
||||
* - sessionId: Eindeutige Session-ID für den Upload
|
||||
*/
|
||||
router.post('/browser-upload', gitUpload.array('files', 500), async (req, res) => {
|
||||
const sessionId = req.body.sessionId || Date.now().toString();
|
||||
const sessionDir = path.join(TEMP_UPLOAD_DIR, sessionId);
|
||||
|
||||
try {
|
||||
const { repoUrl, branch = 'main', commitMessage } = req.body;
|
||||
const files = req.files;
|
||||
|
||||
// Validierung
|
||||
if (!repoUrl) {
|
||||
return res.status(400).json({ error: 'Repository-URL ist erforderlich' });
|
||||
}
|
||||
|
||||
if (!commitMessage) {
|
||||
return res.status(400).json({ error: 'Commit-Nachricht ist erforderlich' });
|
||||
}
|
||||
|
||||
if (!files || files.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine Dateien hochgeladen' });
|
||||
}
|
||||
|
||||
logger.info(`[Browser-Upload] ${files.length} Dateien empfangen für ${repoUrl}`);
|
||||
|
||||
// Git-Repository initialisieren
|
||||
const initResult = gitService.initRepository(sessionDir);
|
||||
if (!initResult.success) {
|
||||
throw new Error('Git-Initialisierung fehlgeschlagen: ' + initResult.error);
|
||||
}
|
||||
|
||||
// Remote hinzufügen
|
||||
const remoteResult = gitService.addRemote(sessionDir, repoUrl, 'origin');
|
||||
if (!remoteResult.success) {
|
||||
throw new Error('Remote hinzufügen fehlgeschlagen: ' + remoteResult.error);
|
||||
}
|
||||
|
||||
// Autor aus eingeloggtem Benutzer
|
||||
const author = req.user ? {
|
||||
name: req.user.display_name || req.user.username,
|
||||
email: req.user.email || `${req.user.username.toLowerCase()}@taskmate.local`
|
||||
} : null;
|
||||
|
||||
// Alle Dateien stagen
|
||||
const stageResult = gitService.stageAll(sessionDir);
|
||||
if (!stageResult.success) {
|
||||
throw new Error('Staging fehlgeschlagen: ' + stageResult.error);
|
||||
}
|
||||
|
||||
// Commit erstellen
|
||||
const commitResult = gitService.commit(sessionDir, commitMessage, author);
|
||||
if (!commitResult.success) {
|
||||
throw new Error('Commit fehlgeschlagen: ' + commitResult.error);
|
||||
}
|
||||
|
||||
// Push mit Upstream
|
||||
const pushResult = gitService.pushWithUpstream(sessionDir, branch, 'origin', false);
|
||||
if (!pushResult.success) {
|
||||
// Bei Fehler: Versuche Force-Push falls Branch existiert
|
||||
if (pushResult.error && pushResult.error.includes('rejected')) {
|
||||
logger.warn('[Browser-Upload] Normaler Push abgelehnt, versuche mit Force...');
|
||||
const forcePushResult = gitService.pushWithUpstream(sessionDir, branch, 'origin', true);
|
||||
if (!forcePushResult.success) {
|
||||
throw new Error('Push fehlgeschlagen: ' + forcePushResult.error);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Push fehlgeschlagen: ' + pushResult.error);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[Browser-Upload] Erfolgreich nach ${repoUrl}/${branch} gepusht`);
|
||||
|
||||
// Erfolgreich - Aufräumen
|
||||
deleteFolderRecursive(sessionDir);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${files.length} Dateien erfolgreich nach ${branch} gepusht`,
|
||||
filesCount: files.length,
|
||||
branch: branch,
|
||||
commit: commitResult.hash || null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[Browser-Upload] Fehler:', error);
|
||||
|
||||
// Bei Fehler aufräumen
|
||||
try {
|
||||
deleteFolderRecursive(sessionDir);
|
||||
} catch (cleanupError) {
|
||||
logger.warn('[Browser-Upload] Aufräumen fehlgeschlagen:', cleanupError);
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message || 'Upload fehlgeschlagen'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/browser-upload-prepare
|
||||
* Bereitet einen Upload vor und gibt eine Session-ID zurück
|
||||
*/
|
||||
router.post('/browser-upload-prepare', (req, res) => {
|
||||
const sessionId = `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
const sessionDir = path.join(TEMP_UPLOAD_DIR, sessionId);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(sessionDir, { recursive: true });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId: sessionId,
|
||||
maxFileSize: 50 * 1024 * 1024, // 50MB
|
||||
maxFiles: 500
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[Browser-Upload] Prepare fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'Vorbereitung fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/git/browser-upload/:sessionId
|
||||
* Löscht eine Upload-Session (für Abbruch)
|
||||
*/
|
||||
router.delete('/browser-upload/:sessionId', (req, res) => {
|
||||
const { sessionId } = req.params;
|
||||
const sessionDir = path.join(TEMP_UPLOAD_DIR, sessionId);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(sessionDir)) {
|
||||
deleteFolderRecursive(sessionDir);
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('[Browser-Upload] Löschen fehlgeschlagen:', error);
|
||||
res.status(500).json({ error: 'Löschen fehlgeschlagen' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
993
backend/routes/knowledge.js
Normale Datei
993
backend/routes/knowledge.js
Normale Datei
@ -0,0 +1,993 @@
|
||||
/**
|
||||
* TASKMATE - Knowledge Management Routes
|
||||
* ======================================
|
||||
* CRUD für Wissensmanagement: Kategorien, Einträge, Anhänge
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const multer = require('multer');
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators, stripHtml } = require('../middleware/validation');
|
||||
const notificationService = require('../services/notificationService');
|
||||
|
||||
// Upload-Konfiguration für Knowledge-Anhänge
|
||||
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads', 'knowledge');
|
||||
|
||||
// Sicherstellen, dass das Upload-Verzeichnis existiert
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, UPLOAD_DIR);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `knowledge-${uniqueSuffix}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: {
|
||||
fileSize: 50 * 1024 * 1024 // 50MB max
|
||||
}
|
||||
});
|
||||
|
||||
// =====================
|
||||
// KATEGORIEN
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/categories
|
||||
* Alle Kategorien abrufen
|
||||
*/
|
||||
router.get('/categories', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const categories = db.prepare(`
|
||||
SELECT kc.*,
|
||||
(SELECT COUNT(*) FROM knowledge_entries WHERE category_id = kc.id) as entry_count,
|
||||
u.display_name as creator_name
|
||||
FROM knowledge_categories kc
|
||||
LEFT JOIN users u ON kc.created_by = u.id
|
||||
ORDER BY kc.position, kc.created_at
|
||||
`).all();
|
||||
|
||||
res.json(categories.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
color: c.color,
|
||||
icon: c.icon,
|
||||
position: c.position,
|
||||
entryCount: c.entry_count,
|
||||
createdBy: c.created_by,
|
||||
creatorName: c.creator_name,
|
||||
createdAt: c.created_at
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Knowledge-Kategorien:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/knowledge/categories
|
||||
* Neue Kategorie erstellen
|
||||
*/
|
||||
router.post('/categories', (req, res) => {
|
||||
try {
|
||||
const { name, description, color, icon } = req.body;
|
||||
|
||||
// Validierung
|
||||
const nameError = validators.required(name, 'Name') ||
|
||||
validators.maxLength(name, 50, 'Name');
|
||||
if (nameError) {
|
||||
return res.status(400).json({ error: nameError });
|
||||
}
|
||||
|
||||
if (color) {
|
||||
const colorError = validators.hexColor(color, 'Farbe');
|
||||
if (colorError) {
|
||||
return res.status(400).json({ error: colorError });
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Duplikat-Prüfung
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM knowledge_categories WHERE LOWER(name) = LOWER(?)'
|
||||
).get(name);
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Eine Kategorie mit diesem Namen existiert bereits' });
|
||||
}
|
||||
|
||||
// Alle bestehenden Kategorien um 1 nach unten verschieben
|
||||
db.prepare(`
|
||||
UPDATE knowledge_categories
|
||||
SET position = position + 1
|
||||
`).run();
|
||||
|
||||
// Neue Kategorie an Position 0 (ganz oben) einfügen
|
||||
const result = db.prepare(`
|
||||
INSERT INTO knowledge_categories (name, description, color, icon, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
stripHtml(name),
|
||||
description ? stripHtml(description) : null,
|
||||
color || '#3B82F6',
|
||||
icon || null,
|
||||
0, // Neue Kategorien immer an Position 0 (oben)
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Knowledge-Kategorie erstellt: ${name}`);
|
||||
|
||||
res.status(201).json({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
color: category.color,
|
||||
icon: category.icon,
|
||||
position: category.position,
|
||||
entryCount: 0,
|
||||
createdBy: category.created_by,
|
||||
createdAt: category.created_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen der Knowledge-Kategorie:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/knowledge/categories/:id
|
||||
* Kategorie aktualisieren
|
||||
*/
|
||||
router.put('/categories/:id', (req, res) => {
|
||||
try {
|
||||
const categoryId = req.params.id;
|
||||
const { name, description, color, icon } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
||||
}
|
||||
|
||||
// Validierung
|
||||
if (name) {
|
||||
const nameError = validators.maxLength(name, 50, 'Name');
|
||||
if (nameError) {
|
||||
return res.status(400).json({ error: nameError });
|
||||
}
|
||||
|
||||
// Duplikat-Prüfung (ausser eigene)
|
||||
const duplicate = db.prepare(
|
||||
'SELECT id FROM knowledge_categories WHERE LOWER(name) = LOWER(?) AND id != ?'
|
||||
).get(name, categoryId);
|
||||
|
||||
if (duplicate) {
|
||||
return res.status(400).json({ error: 'Eine Kategorie mit diesem Namen existiert bereits' });
|
||||
}
|
||||
}
|
||||
|
||||
if (color) {
|
||||
const colorError = validators.hexColor(color, 'Farbe');
|
||||
if (colorError) {
|
||||
return res.status(400).json({ error: colorError });
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE knowledge_categories SET
|
||||
name = COALESCE(?, name),
|
||||
description = COALESCE(?, description),
|
||||
color = COALESCE(?, color),
|
||||
icon = COALESCE(?, icon)
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name ? stripHtml(name) : null,
|
||||
description !== undefined ? (description ? stripHtml(description) : '') : null,
|
||||
color || null,
|
||||
icon !== undefined ? icon : null,
|
||||
categoryId
|
||||
);
|
||||
|
||||
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
|
||||
|
||||
logger.info(`Knowledge-Kategorie aktualisiert: ${category.name}`);
|
||||
|
||||
res.json({
|
||||
id: category.id,
|
||||
name: category.name,
|
||||
description: category.description,
|
||||
color: category.color,
|
||||
icon: category.icon,
|
||||
position: category.position,
|
||||
createdBy: category.created_by,
|
||||
createdAt: category.created_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren der Knowledge-Kategorie:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/knowledge/categories/:id/position
|
||||
* Kategorie-Position ändern
|
||||
*/
|
||||
router.put('/categories/:id/position', (req, res) => {
|
||||
try {
|
||||
const categoryId = req.params.id;
|
||||
const { newPosition } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
||||
}
|
||||
|
||||
const oldPosition = category.position;
|
||||
|
||||
if (newPosition > oldPosition) {
|
||||
db.prepare(`
|
||||
UPDATE knowledge_categories SET position = position - 1
|
||||
WHERE position > ? AND position <= ?
|
||||
`).run(oldPosition, newPosition);
|
||||
} else if (newPosition < oldPosition) {
|
||||
db.prepare(`
|
||||
UPDATE knowledge_categories SET position = position + 1
|
||||
WHERE position >= ? AND position < ?
|
||||
`).run(newPosition, oldPosition);
|
||||
}
|
||||
|
||||
db.prepare('UPDATE knowledge_categories SET position = ? WHERE id = ?').run(newPosition, categoryId);
|
||||
|
||||
const categories = db.prepare(
|
||||
'SELECT * FROM knowledge_categories ORDER BY position'
|
||||
).all();
|
||||
|
||||
res.json({
|
||||
categories: categories.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
position: c.position
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Verschieben der Knowledge-Kategorie:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/knowledge/categories/:id
|
||||
* Kategorie löschen (inkl. aller Einträge und Anhänge)
|
||||
*/
|
||||
router.delete('/categories/:id', (req, res) => {
|
||||
try {
|
||||
const categoryId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
||||
}
|
||||
|
||||
// Anhänge von allen Einträgen dieser Kategorie löschen
|
||||
const entries = db.prepare('SELECT id FROM knowledge_entries WHERE category_id = ?').all(categoryId);
|
||||
for (const entry of entries) {
|
||||
const attachments = db.prepare('SELECT * FROM knowledge_attachments WHERE entry_id = ?').all(entry.id);
|
||||
for (const attachment of attachments) {
|
||||
const filePath = path.join(UPLOAD_DIR, attachment.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kategorie löschen (CASCADE löscht Einträge und Anhänge aus DB)
|
||||
db.prepare('DELETE FROM knowledge_categories WHERE id = ?').run(categoryId);
|
||||
|
||||
// Positionen neu nummerieren
|
||||
const remaining = db.prepare(
|
||||
'SELECT id FROM knowledge_categories ORDER BY position'
|
||||
).all();
|
||||
remaining.forEach((c, idx) => {
|
||||
db.prepare('UPDATE knowledge_categories SET position = ? WHERE id = ?').run(idx, c.id);
|
||||
});
|
||||
|
||||
logger.info(`Knowledge-Kategorie gelöscht: ${category.name}`);
|
||||
|
||||
res.json({ message: 'Kategorie gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen der Knowledge-Kategorie:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// =====================
|
||||
// EINTRÄGE
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries
|
||||
* Einträge abrufen (optional gefiltert nach Kategorie)
|
||||
*/
|
||||
router.get('/entries', (req, res) => {
|
||||
try {
|
||||
const { categoryId } = req.query;
|
||||
const db = getDb();
|
||||
|
||||
let query = `
|
||||
SELECT ke.*,
|
||||
kc.name as category_name,
|
||||
kc.color as category_color,
|
||||
u.display_name as creator_name,
|
||||
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
|
||||
FROM knowledge_entries ke
|
||||
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
|
||||
LEFT JOIN users u ON ke.created_by = u.id
|
||||
`;
|
||||
|
||||
const params = [];
|
||||
if (categoryId) {
|
||||
query += ' WHERE ke.category_id = ?';
|
||||
params.push(categoryId);
|
||||
}
|
||||
|
||||
query += ' ORDER BY ke.position, ke.created_at DESC';
|
||||
|
||||
const entries = db.prepare(query).all(...params);
|
||||
|
||||
res.json(entries.map(e => ({
|
||||
id: e.id,
|
||||
categoryId: e.category_id,
|
||||
categoryName: e.category_name,
|
||||
categoryColor: e.category_color,
|
||||
title: e.title,
|
||||
url: e.url,
|
||||
notes: e.notes,
|
||||
position: e.position,
|
||||
attachmentCount: e.attachment_count,
|
||||
createdBy: e.created_by,
|
||||
creatorName: e.creator_name,
|
||||
createdAt: e.created_at,
|
||||
updatedAt: e.updated_at
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Knowledge-Einträge:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/entries/:id
|
||||
* Einzelnen Eintrag abrufen (mit Anhängen)
|
||||
*/
|
||||
router.get('/entries/:id', (req, res) => {
|
||||
try {
|
||||
const entryId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const entry = db.prepare(`
|
||||
SELECT ke.*,
|
||||
kc.name as category_name,
|
||||
kc.color as category_color,
|
||||
u.display_name as creator_name
|
||||
FROM knowledge_entries ke
|
||||
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
|
||||
LEFT JOIN users u ON ke.created_by = u.id
|
||||
WHERE ke.id = ?
|
||||
`).get(entryId);
|
||||
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
|
||||
const attachments = db.prepare(`
|
||||
SELECT ka.*, u.display_name as uploader_name
|
||||
FROM knowledge_attachments ka
|
||||
LEFT JOIN users u ON ka.uploaded_by = u.id
|
||||
WHERE ka.entry_id = ?
|
||||
ORDER BY ka.uploaded_at DESC
|
||||
`).all(entryId);
|
||||
|
||||
res.json({
|
||||
id: entry.id,
|
||||
categoryId: entry.category_id,
|
||||
categoryName: entry.category_name,
|
||||
categoryColor: entry.category_color,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
notes: entry.notes,
|
||||
position: entry.position,
|
||||
createdBy: entry.created_by,
|
||||
creatorName: entry.creator_name,
|
||||
createdAt: entry.created_at,
|
||||
updatedAt: entry.updated_at,
|
||||
attachments: attachments.map(a => ({
|
||||
id: a.id,
|
||||
filename: a.filename,
|
||||
originalName: a.original_name,
|
||||
mimeType: a.mime_type,
|
||||
sizeBytes: a.size_bytes,
|
||||
uploadedBy: a.uploaded_by,
|
||||
uploaderName: a.uploader_name,
|
||||
uploadedAt: a.uploaded_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des Knowledge-Eintrags:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/knowledge/entries
|
||||
* Neuen Eintrag erstellen
|
||||
*/
|
||||
router.post('/entries', (req, res) => {
|
||||
try {
|
||||
const { categoryId, title, url, notes } = req.body;
|
||||
|
||||
// Validierung
|
||||
const categoryError = validators.required(categoryId, 'Kategorie');
|
||||
if (categoryError) {
|
||||
return res.status(400).json({ error: categoryError });
|
||||
}
|
||||
|
||||
const titleError = validators.required(title, 'Titel') ||
|
||||
validators.maxLength(title, 200, 'Titel');
|
||||
if (titleError) {
|
||||
return res.status(400).json({ error: titleError });
|
||||
}
|
||||
|
||||
if (url) {
|
||||
const urlError = validators.url(url, 'URL');
|
||||
if (urlError) {
|
||||
return res.status(400).json({ error: urlError });
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Kategorie prüfen
|
||||
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
||||
}
|
||||
|
||||
// Alle bestehenden Einträge um 1 nach unten verschieben
|
||||
db.prepare(`
|
||||
UPDATE knowledge_entries
|
||||
SET position = position + 1
|
||||
WHERE category_id = ?
|
||||
`).run(categoryId);
|
||||
|
||||
// Neuen Eintrag an Position 0 (ganz oben) einfügen
|
||||
const result = db.prepare(`
|
||||
INSERT INTO knowledge_entries (category_id, title, url, notes, position, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
categoryId,
|
||||
stripHtml(title),
|
||||
url || null,
|
||||
notes || null,
|
||||
0, // Neue Einträge immer an Position 0 (oben)
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Knowledge-Eintrag erstellt: ${title}`);
|
||||
|
||||
// Benachrichtigung an alle Nutzer senden
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
// Alle Nutzer abrufen (außer dem Ersteller)
|
||||
const users = db.prepare(`
|
||||
SELECT id FROM users
|
||||
WHERE id != ?
|
||||
`).all(req.user.id);
|
||||
|
||||
// Benachrichtigung für jeden Nutzer erstellen
|
||||
users.forEach(user => {
|
||||
notificationService.create(
|
||||
user.id,
|
||||
'knowledge:new_entry',
|
||||
{
|
||||
entryId: entry.id,
|
||||
entryTitle: entry.title,
|
||||
categoryName: category.name,
|
||||
categoryId: category.id,
|
||||
projectId: null,
|
||||
actorId: req.user.id
|
||||
},
|
||||
io,
|
||||
false // nicht persistent
|
||||
);
|
||||
});
|
||||
|
||||
// Socket.io Event für Echtzeit-Update senden
|
||||
io.emit('knowledge:created', {
|
||||
entry: {
|
||||
id: entry.id,
|
||||
categoryId: entry.category_id,
|
||||
categoryName: category.name,
|
||||
categoryColor: category.color,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
notes: entry.notes,
|
||||
position: entry.position,
|
||||
attachmentCount: 0,
|
||||
createdBy: entry.created_by,
|
||||
creatorName: req.user.display_name,
|
||||
createdAt: entry.created_at,
|
||||
updatedAt: entry.updated_at
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
id: entry.id,
|
||||
categoryId: entry.category_id,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
notes: entry.notes,
|
||||
position: entry.position,
|
||||
attachmentCount: 0,
|
||||
createdBy: entry.created_by,
|
||||
createdAt: entry.created_at,
|
||||
updatedAt: entry.updated_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Knowledge-Eintrags:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/knowledge/entries/:id
|
||||
* Eintrag aktualisieren
|
||||
*/
|
||||
router.put('/entries/:id', (req, res) => {
|
||||
try {
|
||||
const entryId = req.params.id;
|
||||
const { categoryId, title, url, notes } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
|
||||
// Validierung
|
||||
if (title) {
|
||||
const titleError = validators.maxLength(title, 200, 'Titel');
|
||||
if (titleError) {
|
||||
return res.status(400).json({ error: titleError });
|
||||
}
|
||||
}
|
||||
|
||||
if (url) {
|
||||
const urlError = validators.url(url, 'URL');
|
||||
if (urlError) {
|
||||
return res.status(400).json({ error: urlError });
|
||||
}
|
||||
}
|
||||
|
||||
if (categoryId) {
|
||||
const category = db.prepare('SELECT * FROM knowledge_categories WHERE id = ?').get(categoryId);
|
||||
if (!category) {
|
||||
return res.status(404).json({ error: 'Kategorie nicht gefunden' });
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE knowledge_entries SET
|
||||
category_id = COALESCE(?, category_id),
|
||||
title = COALESCE(?, title),
|
||||
url = ?,
|
||||
notes = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
categoryId || null,
|
||||
title ? stripHtml(title) : null,
|
||||
url !== undefined ? url : existing.url,
|
||||
notes !== undefined ? notes : existing.notes,
|
||||
entryId
|
||||
);
|
||||
|
||||
const entry = db.prepare(`
|
||||
SELECT ke.*, kc.name as category_name, kc.color as category_color
|
||||
FROM knowledge_entries ke
|
||||
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
|
||||
WHERE ke.id = ?
|
||||
`).get(entryId);
|
||||
|
||||
logger.info(`Knowledge-Eintrag aktualisiert: ${entry.title}`);
|
||||
|
||||
res.json({
|
||||
id: entry.id,
|
||||
categoryId: entry.category_id,
|
||||
categoryName: entry.category_name,
|
||||
categoryColor: entry.category_color,
|
||||
title: entry.title,
|
||||
url: entry.url,
|
||||
notes: entry.notes,
|
||||
position: entry.position,
|
||||
createdBy: entry.created_by,
|
||||
createdAt: entry.created_at,
|
||||
updatedAt: entry.updated_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren des Knowledge-Eintrags:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/knowledge/entries/:id/position
|
||||
* Eintrag-Position ändern (oder in andere Kategorie verschieben)
|
||||
*/
|
||||
router.put('/entries/:id/position', (req, res) => {
|
||||
try {
|
||||
const entryId = req.params.id;
|
||||
const { newPosition, newCategoryId } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
|
||||
const oldPosition = entry.position;
|
||||
const oldCategoryId = entry.category_id;
|
||||
const targetCategoryId = newCategoryId || oldCategoryId;
|
||||
|
||||
// Wenn Kategorie wechselt
|
||||
if (targetCategoryId !== oldCategoryId) {
|
||||
// Alte Kategorie: Positionen nach dem entfernten Eintrag reduzieren
|
||||
db.prepare(`
|
||||
UPDATE knowledge_entries SET position = position - 1
|
||||
WHERE category_id = ? AND position > ?
|
||||
`).run(oldCategoryId, oldPosition);
|
||||
|
||||
// Neue Kategorie: Platz für neuen Eintrag schaffen
|
||||
db.prepare(`
|
||||
UPDATE knowledge_entries SET position = position + 1
|
||||
WHERE category_id = ? AND position >= ?
|
||||
`).run(targetCategoryId, newPosition);
|
||||
|
||||
// Eintrag verschieben
|
||||
db.prepare(`
|
||||
UPDATE knowledge_entries SET category_id = ?, position = ?
|
||||
WHERE id = ?
|
||||
`).run(targetCategoryId, newPosition, entryId);
|
||||
} else {
|
||||
// Innerhalb der gleichen Kategorie
|
||||
if (newPosition > oldPosition) {
|
||||
db.prepare(`
|
||||
UPDATE knowledge_entries SET position = position - 1
|
||||
WHERE category_id = ? AND position > ? AND position <= ?
|
||||
`).run(oldCategoryId, oldPosition, newPosition);
|
||||
} else if (newPosition < oldPosition) {
|
||||
db.prepare(`
|
||||
UPDATE knowledge_entries SET position = position + 1
|
||||
WHERE category_id = ? AND position >= ? AND position < ?
|
||||
`).run(oldCategoryId, newPosition, oldPosition);
|
||||
}
|
||||
|
||||
db.prepare('UPDATE knowledge_entries SET position = ? WHERE id = ?').run(newPosition, entryId);
|
||||
}
|
||||
|
||||
res.json({ message: 'Position aktualisiert' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Verschieben des Knowledge-Eintrags:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/knowledge/entries/:id
|
||||
* Eintrag löschen
|
||||
*/
|
||||
router.delete('/entries/:id', (req, res) => {
|
||||
try {
|
||||
const entryId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
|
||||
// Anhänge vom Dateisystem löschen
|
||||
const attachments = db.prepare('SELECT * FROM knowledge_attachments WHERE entry_id = ?').all(entryId);
|
||||
for (const attachment of attachments) {
|
||||
const filePath = path.join(UPLOAD_DIR, attachment.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
const categoryId = entry.category_id;
|
||||
|
||||
// Eintrag löschen
|
||||
db.prepare('DELETE FROM knowledge_entries WHERE id = ?').run(entryId);
|
||||
|
||||
// Positionen neu nummerieren
|
||||
const remaining = db.prepare(
|
||||
'SELECT id FROM knowledge_entries WHERE category_id = ? ORDER BY position'
|
||||
).all(categoryId);
|
||||
remaining.forEach((e, idx) => {
|
||||
db.prepare('UPDATE knowledge_entries SET position = ? WHERE id = ?').run(idx, e.id);
|
||||
});
|
||||
|
||||
logger.info(`Knowledge-Eintrag gelöscht: ${entry.title}`);
|
||||
|
||||
res.json({ message: 'Eintrag gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Knowledge-Eintrags:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// =====================
|
||||
// ANHÄNGE
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/attachments/:entryId
|
||||
* Anhänge eines Eintrags abrufen
|
||||
*/
|
||||
router.get('/attachments/:entryId', (req, res) => {
|
||||
try {
|
||||
const entryId = req.params.entryId;
|
||||
const db = getDb();
|
||||
|
||||
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
|
||||
if (!entry) {
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
|
||||
const attachments = db.prepare(`
|
||||
SELECT ka.*, u.display_name as uploader_name
|
||||
FROM knowledge_attachments ka
|
||||
LEFT JOIN users u ON ka.uploaded_by = u.id
|
||||
WHERE ka.entry_id = ?
|
||||
ORDER BY ka.uploaded_at DESC
|
||||
`).all(entryId);
|
||||
|
||||
res.json(attachments.map(a => ({
|
||||
id: a.id,
|
||||
entryId: a.entry_id,
|
||||
filename: a.filename,
|
||||
originalName: a.original_name,
|
||||
mimeType: a.mime_type,
|
||||
sizeBytes: a.size_bytes,
|
||||
uploadedBy: a.uploaded_by,
|
||||
uploaderName: a.uploader_name,
|
||||
uploadedAt: a.uploaded_at
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Knowledge-Anhänge:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/knowledge/attachments/:entryId
|
||||
* Anhang hochladen
|
||||
*/
|
||||
router.post('/attachments/:entryId', upload.single('files'), (req, res) => {
|
||||
try {
|
||||
const entryId = req.params.entryId;
|
||||
const db = getDb();
|
||||
|
||||
const entry = db.prepare('SELECT * FROM knowledge_entries WHERE id = ?').get(entryId);
|
||||
if (!entry) {
|
||||
// Hochgeladene Datei löschen
|
||||
if (req.file) {
|
||||
fs.unlinkSync(req.file.path);
|
||||
}
|
||||
return res.status(404).json({ error: 'Eintrag nicht gefunden' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'Keine Datei hochgeladen' });
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO knowledge_attachments (entry_id, filename, original_name, mime_type, size_bytes, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
entryId,
|
||||
req.file.filename,
|
||||
req.file.originalname,
|
||||
req.file.mimetype,
|
||||
req.file.size,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
// Eintrag updated_at aktualisieren
|
||||
db.prepare('UPDATE knowledge_entries SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(entryId);
|
||||
|
||||
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?')
|
||||
.get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Knowledge-Anhang hochgeladen: ${req.file.originalname} für Eintrag ${entry.title}`);
|
||||
|
||||
res.status(201).json({
|
||||
id: attachment.id,
|
||||
entryId: attachment.entry_id,
|
||||
filename: attachment.filename,
|
||||
originalName: attachment.original_name,
|
||||
mimeType: attachment.mime_type,
|
||||
sizeBytes: attachment.size_bytes,
|
||||
uploadedBy: attachment.uploaded_by,
|
||||
uploadedAt: attachment.uploaded_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Hochladen des Knowledge-Anhangs:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/attachments/download/:id
|
||||
* Anhang herunterladen
|
||||
*/
|
||||
router.get('/attachments/download/:id', (req, res) => {
|
||||
try {
|
||||
const attachmentId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?').get(attachmentId);
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, attachment.filename);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
}
|
||||
|
||||
res.download(filePath, attachment.original_name);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Herunterladen des Knowledge-Anhangs:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/knowledge/attachments/:id
|
||||
* Anhang löschen
|
||||
*/
|
||||
router.delete('/attachments/:id', (req, res) => {
|
||||
try {
|
||||
const attachmentId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const attachment = db.prepare('SELECT * FROM knowledge_attachments WHERE id = ?').get(attachmentId);
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ error: 'Anhang nicht gefunden' });
|
||||
}
|
||||
|
||||
// Datei vom Dateisystem löschen
|
||||
const filePath = path.join(UPLOAD_DIR, attachment.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
// Aus Datenbank löschen
|
||||
db.prepare('DELETE FROM knowledge_attachments WHERE id = ?').run(attachmentId);
|
||||
|
||||
// Eintrag updated_at aktualisieren
|
||||
db.prepare('UPDATE knowledge_entries SET updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(attachment.entry_id);
|
||||
|
||||
logger.info(`Knowledge-Anhang gelöscht: ${attachment.original_name}`);
|
||||
|
||||
res.json({ message: 'Anhang gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Knowledge-Anhangs:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
// =====================
|
||||
// SUCHE
|
||||
// =====================
|
||||
|
||||
/**
|
||||
* GET /api/knowledge/search
|
||||
* Wissensmanagement durchsuchen
|
||||
*/
|
||||
router.get('/search', (req, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
|
||||
if (!q || q.trim().length < 2) {
|
||||
return res.json({ categories: [], entries: [] });
|
||||
}
|
||||
|
||||
const searchTerm = `%${q.toLowerCase()}%`;
|
||||
const db = getDb();
|
||||
|
||||
// Kategorien durchsuchen
|
||||
const categories = db.prepare(`
|
||||
SELECT kc.*,
|
||||
(SELECT COUNT(*) FROM knowledge_entries WHERE category_id = kc.id) as entry_count
|
||||
FROM knowledge_categories kc
|
||||
WHERE LOWER(kc.name) LIKE ? OR LOWER(kc.description) LIKE ?
|
||||
ORDER BY kc.position
|
||||
`).all(searchTerm, searchTerm);
|
||||
|
||||
// Einträge durchsuchen
|
||||
const entries = db.prepare(`
|
||||
SELECT ke.*,
|
||||
kc.name as category_name,
|
||||
kc.color as category_color,
|
||||
(SELECT COUNT(*) FROM knowledge_attachments WHERE entry_id = ke.id) as attachment_count
|
||||
FROM knowledge_entries ke
|
||||
LEFT JOIN knowledge_categories kc ON ke.category_id = kc.id
|
||||
WHERE LOWER(ke.title) LIKE ? OR LOWER(ke.notes) LIKE ? OR LOWER(ke.url) LIKE ?
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(ke.title) LIKE ? THEN 1
|
||||
ELSE 2
|
||||
END,
|
||||
ke.created_at DESC
|
||||
LIMIT 50
|
||||
`).all(searchTerm, searchTerm, searchTerm, searchTerm);
|
||||
|
||||
res.json({
|
||||
categories: categories.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
color: c.color,
|
||||
icon: c.icon,
|
||||
entryCount: c.entry_count
|
||||
})),
|
||||
entries: entries.map(e => ({
|
||||
id: e.id,
|
||||
categoryId: e.category_id,
|
||||
categoryName: e.category_name,
|
||||
categoryColor: e.category_color,
|
||||
title: e.title,
|
||||
url: e.url,
|
||||
notes: e.notes,
|
||||
attachmentCount: e.attachment_count,
|
||||
createdAt: e.created_at
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei der Knowledge-Suche:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
360
backend/routes/reminders.js
Normale Datei
360
backend/routes/reminders.js
Normale Datei
@ -0,0 +1,360 @@
|
||||
/**
|
||||
* TASKMATE - Reminders API
|
||||
* ========================
|
||||
* API endpoints für Erinnerungen
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const reminderService = require('../services/reminderService');
|
||||
|
||||
// GET /api/reminders - Alle Erinnerungen für ein Projekt abrufen
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const { project_id } = req.query;
|
||||
const db = getDb();
|
||||
|
||||
if (!project_id) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'project_id ist erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const reminders = db.prepare(`
|
||||
SELECT r.*, u.display_name as creator_name
|
||||
FROM reminders r
|
||||
LEFT JOIN users u ON r.created_by = u.id
|
||||
WHERE r.project_id = ? AND r.is_active = 1
|
||||
ORDER BY r.reminder_date ASC, r.reminder_time ASC
|
||||
`).all(project_id);
|
||||
|
||||
// Advance days von String zu Array konvertieren
|
||||
reminders.forEach(reminder => {
|
||||
reminder.advance_days = reminder.advance_days ? reminder.advance_days.split(',') : ['1'];
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: reminders
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching reminders:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reminders/:id - Einzelne Erinnerung abrufen
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const reminder = db.prepare(`
|
||||
SELECT r.*, u.display_name as creator_name
|
||||
FROM reminders r
|
||||
LEFT JOIN users u ON r.created_by = u.id
|
||||
WHERE r.id = ?
|
||||
`).get(req.params.id);
|
||||
|
||||
if (!reminder) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Erinnerung nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Advance days von String zu Array konvertieren
|
||||
reminder.advance_days = reminder.advance_days ? reminder.advance_days.split(',') : ['1'];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: reminder
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching reminder:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/reminders - Neue Erinnerung erstellen
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
reminder_date,
|
||||
reminder_time,
|
||||
color,
|
||||
advance_days,
|
||||
repeat_type,
|
||||
repeat_interval
|
||||
} = req.body;
|
||||
|
||||
if (!project_id || !title || !reminder_date) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'project_id, title und reminder_date sind erforderlich'
|
||||
});
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Advance days Array zu String konvertieren
|
||||
const advanceDaysStr = Array.isArray(advance_days) ? advance_days.join(',') : '1';
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO reminders (
|
||||
project_id, title, description, reminder_date, reminder_time,
|
||||
color, advance_days, repeat_type, repeat_interval, created_by
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
project_id,
|
||||
title,
|
||||
description || null,
|
||||
reminder_date,
|
||||
reminder_time || '09:00',
|
||||
color || '#F59E0B',
|
||||
advanceDaysStr,
|
||||
repeat_type || 'none',
|
||||
repeat_interval || 1,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
// Benachrichtigungs-Termine mit ReminderService erstellen
|
||||
if (advance_days && Array.isArray(advance_days)) {
|
||||
const serviceInstance = reminderService.getInstance();
|
||||
serviceInstance.createNotificationSchedule(result.lastInsertRowid, reminder_date, advance_days);
|
||||
}
|
||||
|
||||
// Neue Erinnerung abrufen
|
||||
const newReminder = db.prepare(`
|
||||
SELECT r.*, u.display_name as creator_name
|
||||
FROM reminders r
|
||||
LEFT JOIN users u ON r.created_by = u.id
|
||||
WHERE r.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
newReminder.advance_days = newReminder.advance_days ? newReminder.advance_days.split(',') : ['1'];
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: newReminder
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating reminder:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/reminders/:id - Erinnerung bearbeiten
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
reminder_date,
|
||||
reminder_time,
|
||||
color,
|
||||
advance_days,
|
||||
repeat_type,
|
||||
repeat_interval,
|
||||
is_active
|
||||
} = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prüfen ob Erinnerung existiert
|
||||
const existing = db.prepare('SELECT * FROM reminders WHERE id = ?').get(req.params.id);
|
||||
if (!existing) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Erinnerung nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
// Advance days Array zu String konvertieren
|
||||
const advanceDaysStr = Array.isArray(advance_days) ? advance_days.join(',') : existing.advance_days;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE reminders SET
|
||||
title = ?,
|
||||
description = ?,
|
||||
reminder_date = ?,
|
||||
reminder_time = ?,
|
||||
color = ?,
|
||||
advance_days = ?,
|
||||
repeat_type = ?,
|
||||
repeat_interval = ?,
|
||||
is_active = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title || existing.title,
|
||||
description !== undefined ? description : existing.description,
|
||||
reminder_date || existing.reminder_date,
|
||||
reminder_time || existing.reminder_time,
|
||||
color || existing.color,
|
||||
advanceDaysStr,
|
||||
repeat_type || existing.repeat_type,
|
||||
repeat_interval || existing.repeat_interval,
|
||||
is_active !== undefined ? is_active : existing.is_active,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
// Benachrichtigungs-Termine neu berechnen wenn sich das Datum geändert hat
|
||||
if (reminder_date || advance_days) {
|
||||
const finalAdvanceDays = advance_days || existing.advance_days.split(',');
|
||||
const finalReminderDate = reminder_date || existing.reminder_date;
|
||||
|
||||
const serviceInstance = reminderService.getInstance();
|
||||
serviceInstance.createNotificationSchedule(req.params.id, finalReminderDate, finalAdvanceDays);
|
||||
}
|
||||
|
||||
// Aktualisierte Erinnerung abrufen
|
||||
const updatedReminder = db.prepare(`
|
||||
SELECT r.*, u.display_name as creator_name
|
||||
FROM reminders r
|
||||
LEFT JOIN users u ON r.created_by = u.id
|
||||
WHERE r.id = ?
|
||||
`).get(req.params.id);
|
||||
|
||||
updatedReminder.advance_days = updatedReminder.advance_days ? updatedReminder.advance_days.split(',') : ['1'];
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: updatedReminder
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating reminder:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/reminders/:id - Erinnerung löschen
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
const result = db.prepare('DELETE FROM reminders WHERE id = ?').run(req.params.id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Erinnerung nicht gefunden'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Erinnerung erfolgreich gelöscht'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting reminder:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reminders/due/check - Fällige Erinnerungen prüfen (für Cron-Job)
|
||||
router.get('/due/check', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Fällige Benachrichtigungen finden
|
||||
const dueNotifications = db.prepare(`
|
||||
SELECT rn.*, r.title, r.description, r.project_id, r.created_by, r.reminder_date, r.color
|
||||
FROM reminder_notifications rn
|
||||
JOIN reminders r ON rn.reminder_id = r.id
|
||||
WHERE rn.notification_date <= ? AND rn.sent = 0 AND r.is_active = 1
|
||||
ORDER BY rn.notification_date ASC
|
||||
`).all(today);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: dueNotifications
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error checking due reminders:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/reminders/due/mark-sent - Benachrichtigung als gesendet markieren
|
||||
router.post('/due/mark-sent', (req, res) => {
|
||||
try {
|
||||
const { notification_id } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
db.prepare('UPDATE reminder_notifications SET sent = 1 WHERE id = ?').run(notification_id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Benachrichtigung als gesendet markiert'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error marking notification as sent:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/reminders/stats - Debug-Statistiken für Reminder Service
|
||||
router.get('/stats', (req, res) => {
|
||||
try {
|
||||
const serviceInstance = reminderService.getInstance();
|
||||
const stats = serviceInstance.getStats();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: stats
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error getting reminder stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/reminders/check-now - Manuelle Prüfung fälliger Erinnerungen
|
||||
router.post('/check-now', async (req, res) => {
|
||||
try {
|
||||
const serviceInstance = reminderService.getInstance();
|
||||
await serviceInstance.manualCheck();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Manuelle Reminder-Prüfung durchgeführt'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error during manual reminder check:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Interner Server-Fehler'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -57,16 +57,16 @@ router.post('/', (req, res) => {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
// Höchste Position ermitteln
|
||||
const maxPos = db.prepare(
|
||||
'SELECT COALESCE(MAX(position), -1) as max FROM subtasks WHERE task_id = ?'
|
||||
).get(taskId).max;
|
||||
// Alle bestehenden Subtasks um eine Position nach unten verschieben
|
||||
db.prepare(`
|
||||
UPDATE subtasks SET position = position + 1 WHERE task_id = ?
|
||||
`).run(taskId);
|
||||
|
||||
// Subtask erstellen
|
||||
// Neue Subtask an Position 0 erstellen (immer an erster Stelle)
|
||||
const result = db.prepare(`
|
||||
INSERT INTO subtasks (task_id, title, position)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(taskId, title, maxPos + 1);
|
||||
VALUES (?, ?, 0)
|
||||
`).run(taskId, title);
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
|
||||
|
||||
@ -4,6 +4,9 @@
|
||||
* Node.js/Express Backend mit Socket.io für Echtzeit-Sync
|
||||
*/
|
||||
|
||||
// Umgebungsvariablen laden (muss ganz oben stehen!)
|
||||
require('dotenv').config();
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
@ -38,9 +41,13 @@ const adminRoutes = require('./routes/admin');
|
||||
const proposalRoutes = require('./routes/proposals');
|
||||
const notificationRoutes = require('./routes/notifications');
|
||||
const notificationService = require('./services/notificationService');
|
||||
const reminderService = require('./services/reminderService');
|
||||
const gitRoutes = require('./routes/git');
|
||||
const applicationsRoutes = require('./routes/applications');
|
||||
const giteaRoutes = require('./routes/gitea');
|
||||
const knowledgeRoutes = require('./routes/knowledge');
|
||||
const codingRoutes = require('./routes/coding');
|
||||
const reminderRoutes = require('./routes/reminders');
|
||||
|
||||
// Express App erstellen
|
||||
const app = express();
|
||||
@ -58,17 +65,18 @@ const io = new Server(server, {
|
||||
// MIDDLEWARE
|
||||
// =============================================================================
|
||||
|
||||
// Sicherheits-Header
|
||||
// Erweiterte Sicherheits-Header (CSP temporär deaktiviert für Login-Fix)
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
scriptSrc: ["'self'"],
|
||||
connectSrc: ["'self'", "ws:", "wss:"]
|
||||
}
|
||||
contentSecurityPolicy: false, // Temporär deaktiviert
|
||||
hsts: {
|
||||
maxAge: 31536000, // 1 Jahr
|
||||
includeSubDomains: true,
|
||||
preload: true
|
||||
},
|
||||
noSniff: true,
|
||||
xssFilter: true,
|
||||
referrerPolicy: {
|
||||
policy: "strict-origin-when-cross-origin"
|
||||
}
|
||||
}));
|
||||
|
||||
@ -85,6 +93,10 @@ app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
// Cookie Parser
|
||||
app.use(cookieParser());
|
||||
|
||||
// Input Sanitization (vor allen anderen Middlewares)
|
||||
const { sanitizeMiddleware } = require('./middleware/validation');
|
||||
app.use(sanitizeMiddleware);
|
||||
|
||||
// Request Logging
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
@ -96,8 +108,17 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Statische Dateien (Frontend)
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
// Statische Dateien (Frontend) - ohne Caching für Development
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
etag: false,
|
||||
lastModified: false,
|
||||
cacheControl: false,
|
||||
setHeaders: (res, path) => {
|
||||
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
||||
res.setHeader('Pragma', 'no-cache');
|
||||
res.setHeader('Expires', '0');
|
||||
}
|
||||
}));
|
||||
|
||||
// Uploads-Ordner
|
||||
app.use('/uploads', authenticateToken, express.static(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads')));
|
||||
@ -144,6 +165,18 @@ app.use('/api/applications', authenticateToken, csrfProtection, applicationsRout
|
||||
// Gitea-Routes (Gitea API Integration)
|
||||
app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
|
||||
|
||||
// Knowledge-Routes (Wissensmanagement)
|
||||
app.use('/api/knowledge', authenticateToken, csrfProtection, knowledgeRoutes);
|
||||
|
||||
// Coding-Routes (Entwicklungsverzeichnisse mit Claude/Codex)
|
||||
app.use('/api/coding', authenticateToken, csrfProtection, codingRoutes);
|
||||
|
||||
// Reminder-Routes (Erinnerungen)
|
||||
app.use('/api/reminders', authenticateToken, csrfProtection, reminderRoutes);
|
||||
|
||||
// Contacts-Routes (Kontakte)
|
||||
app.use('/api/contacts', authenticateToken, csrfProtection, require('./routes/contacts'));
|
||||
|
||||
// =============================================================================
|
||||
// SOCKET.IO
|
||||
// =============================================================================
|
||||
@ -280,6 +313,10 @@ database.initialize()
|
||||
notificationService.checkDueTasks(io);
|
||||
logger.info('Fälligkeits-Check für Benachrichtigungen gestartet');
|
||||
}, 60 * 1000);
|
||||
|
||||
// Reminder Service starten
|
||||
const reminderServiceInstance = reminderService.getInstance(io);
|
||||
reminderServiceInstance.start();
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
@ -290,6 +327,11 @@ database.initialize()
|
||||
// Graceful Shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM empfangen, fahre herunter...');
|
||||
|
||||
// Reminder Service stoppen
|
||||
const reminderServiceInstance = reminderService.getInstance();
|
||||
reminderServiceInstance.stop();
|
||||
|
||||
server.close(() => {
|
||||
database.close();
|
||||
logger.info('Server beendet');
|
||||
@ -299,6 +341,11 @@ process.on('SIGTERM', () => {
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT empfangen, fahre herunter...');
|
||||
|
||||
// Reminder Service stoppen
|
||||
const reminderServiceInstance = reminderService.getInstance();
|
||||
reminderServiceInstance.stop();
|
||||
|
||||
server.close(() => {
|
||||
database.close();
|
||||
logger.info('Server beendet');
|
||||
|
||||
@ -21,6 +21,11 @@ function windowsToContainerPath(windowsPath) {
|
||||
return windowsPath;
|
||||
}
|
||||
|
||||
// Spezialfall: TaskMate-Verzeichnis ist als /app/taskmate-source gemountet
|
||||
if (windowsPath === '/home/claude-dev/TaskMate') {
|
||||
return '/app/taskmate-source';
|
||||
}
|
||||
|
||||
// Windows-Pfad konvertieren (z.B. "C:\foo" oder "C:/foo")
|
||||
const normalized = windowsPath.replace(/\\/g, '/');
|
||||
const match = normalized.match(/^([a-zA-Z]):[\/](.*)$/);
|
||||
@ -73,8 +78,12 @@ function isGitRepository(localPath) {
|
||||
const containerPath = windowsToContainerPath(localPath);
|
||||
try {
|
||||
const gitDir = path.join(containerPath, '.git');
|
||||
return fs.existsSync(gitDir);
|
||||
logger.info(`Git-Repository Check: ${localPath} -> ${containerPath} -> ${gitDir}`);
|
||||
const exists = fs.existsSync(gitDir);
|
||||
logger.info(`Git directory exists: ${exists}`);
|
||||
return exists;
|
||||
} catch (error) {
|
||||
logger.error(`Git-Repository Check failed: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -253,8 +262,11 @@ function stageAll(localPath) {
|
||||
|
||||
/**
|
||||
* Commit erstellen
|
||||
* @param {string} localPath - Pfad zum Repository
|
||||
* @param {string} message - Commit-Nachricht
|
||||
* @param {object} author - Autor-Informationen { name, email }
|
||||
*/
|
||||
function commit(localPath, message) {
|
||||
function commit(localPath, message, author = null) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
@ -265,7 +277,17 @@ function commit(localPath, message) {
|
||||
|
||||
// Escape für Shell
|
||||
const escapedMessage = message.replace(/"/g, '\\"');
|
||||
return execGitCommand(`git commit -m "${escapedMessage}"`, localPath);
|
||||
|
||||
// Commit-Befehl mit optionalem Autor
|
||||
let commitCmd = `git commit -m "${escapedMessage}"`;
|
||||
if (author && author.name) {
|
||||
const authorName = author.name.replace(/"/g, '\\"');
|
||||
const authorEmail = author.email || `${author.name.toLowerCase().replace(/\s+/g, '.')}@taskmate.local`;
|
||||
commitCmd = `git commit --author="${authorName} <${authorEmail}>" -m "${escapedMessage}"`;
|
||||
logger.info(`Commit mit Autor: ${authorName} <${authorEmail}>`);
|
||||
}
|
||||
|
||||
return execGitCommand(commitCmd, localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -443,7 +465,7 @@ function hasRemote(localPath, remoteName = 'origin') {
|
||||
/**
|
||||
* Initialen Push mit Upstream-Tracking
|
||||
*/
|
||||
function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
|
||||
function pushWithUpstream(localPath, targetBranch = null, remoteName = 'origin', force = false) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
@ -477,11 +499,12 @@ function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
|
||||
}
|
||||
}
|
||||
|
||||
// Aktuellen Branch ermitteln NACH dem Commit
|
||||
// Aktuellen (lokalen) Branch ermitteln NACH dem Commit
|
||||
let localBranch = null;
|
||||
const branchResult = execGitCommand('git branch --show-current', localPath);
|
||||
if (branchResult.success && branchResult.output) {
|
||||
branch = branchResult.output;
|
||||
logger.info(`Aktueller Branch: ${branch}`);
|
||||
localBranch = branchResult.output;
|
||||
logger.info(`Lokaler Branch: ${localBranch}`);
|
||||
} else {
|
||||
// Fallback: Branch aus git branch auslesen
|
||||
const branchListResult = execGitCommand('git branch', localPath);
|
||||
@ -491,29 +514,46 @@ function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
|
||||
const lines = branchListResult.output.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.includes('*')) {
|
||||
branch = line.replace('*', '').trim();
|
||||
localBranch = line.replace('*', '').trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!branch) {
|
||||
if (!localBranch) {
|
||||
// Letzter Fallback: Versuche den Branch zu erstellen
|
||||
logger.info('Kein Branch gefunden, erstelle main');
|
||||
execGitCommand('git checkout -b main', localPath);
|
||||
branch = 'main';
|
||||
localBranch = 'main';
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Push auf Branch: ${branch}`);
|
||||
// Wenn kein Ziel-Branch angegeben, verwende lokalen Branch-Namen
|
||||
const remoteBranch = targetBranch || localBranch;
|
||||
|
||||
logger.info(`Push: lokaler Branch '${localBranch}' → Remote Branch '${remoteBranch}'${force ? ' (FORCE)' : ''}`);
|
||||
|
||||
// Push mit -u für Upstream-Tracking
|
||||
const pushResult = execGitCommand(`git push -u ${remoteName} ${branch}`, localPath, { timeout: 120000 });
|
||||
// Format: git push -u origin localBranch:remoteBranch
|
||||
const forceFlag = force ? ' --force' : '';
|
||||
let pushCommand;
|
||||
if (localBranch === remoteBranch) {
|
||||
pushCommand = `git push -u${forceFlag} ${remoteName} ${localBranch}`;
|
||||
} else {
|
||||
// Branch-Mapping: lokalen Branch auf anderen Remote-Branch pushen
|
||||
pushCommand = `git push -u${forceFlag} ${remoteName} ${localBranch}:${remoteBranch}`;
|
||||
}
|
||||
|
||||
const pushResult = execGitCommand(pushCommand, localPath, { timeout: 120000 });
|
||||
|
||||
if (!pushResult.success) {
|
||||
logger.error(`Push fehlgeschlagen: ${pushResult.error}`);
|
||||
}
|
||||
|
||||
// Branch-Namen im Ergebnis inkludieren für Default-Branch Aktualisierung
|
||||
pushResult.localBranch = localBranch;
|
||||
pushResult.branch = remoteBranch; // Remote-Branch für Gitea Default-Branch
|
||||
|
||||
return pushResult;
|
||||
}
|
||||
|
||||
@ -561,6 +601,39 @@ function prepareForGitea(localPath, remoteUrl, options = {}) {
|
||||
return { success: true, message: 'Repository für Gitea vorbereitet' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch umbenennen
|
||||
*/
|
||||
function renameBranch(localPath, oldName, newName) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
// Validiere neuen Branch-Namen
|
||||
if (!newName || !/^[a-zA-Z0-9_\-]+$/.test(newName)) {
|
||||
return { success: false, error: 'Ungültiger Branch-Name. Nur Buchstaben, Zahlen, Unterstriche und Bindestriche erlaubt.' };
|
||||
}
|
||||
|
||||
// Prüfe ob wir auf dem zu umbenennenden Branch sind
|
||||
const branchResult = execGitCommand('git branch --show-current', localPath);
|
||||
const currentBranch = branchResult.success ? branchResult.output : null;
|
||||
|
||||
if (currentBranch !== oldName) {
|
||||
return { success: false, error: `Bitte wechsle zuerst zum Branch "${oldName}" bevor du ihn umbenennst.` };
|
||||
}
|
||||
|
||||
// Branch umbenennen
|
||||
const renameResult = execGitCommand(`git branch -m ${oldName} ${newName}`, localPath);
|
||||
|
||||
if (!renameResult.success) {
|
||||
logger.error(`Branch umbenennen fehlgeschlagen: ${renameResult.error}`);
|
||||
return renameResult;
|
||||
}
|
||||
|
||||
logger.info(`Branch umbenannt: ${oldName} → ${newName}`);
|
||||
return { success: true, oldName, newName, message: `Branch "${oldName}" zu "${newName}" umbenannt` };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
windowsToContainerPath,
|
||||
containerToWindowsPath,
|
||||
@ -581,5 +654,6 @@ module.exports = {
|
||||
setRemote,
|
||||
hasRemote,
|
||||
pushWithUpstream,
|
||||
prepareForGitea
|
||||
prepareForGitea,
|
||||
renameBranch
|
||||
};
|
||||
|
||||
@ -284,12 +284,44 @@ function getSafeCloneUrl(cloneUrl) {
|
||||
return cloneUrl.replace(/https:\/\/[^@]+@/, 'https://');
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository-Einstellungen aktualisieren (z.B. default_branch ändern)
|
||||
*/
|
||||
async function updateRepository(owner, repo, options = {}) {
|
||||
try {
|
||||
const updateData = {};
|
||||
if (options.defaultBranch) updateData.default_branch = options.defaultBranch;
|
||||
if (options.description !== undefined) updateData.description = options.description;
|
||||
if (options.private !== undefined) updateData.private = options.private;
|
||||
|
||||
const repoData = await giteaFetch(`/repos/${owner}/${repo}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
logger.info(`Repository ${owner}/${repo} aktualisiert (default_branch: ${repoData.default_branch})`);
|
||||
return {
|
||||
success: true,
|
||||
repository: {
|
||||
id: repoData.id,
|
||||
name: repoData.name,
|
||||
fullName: repoData.full_name,
|
||||
defaultBranch: repoData.default_branch
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Aktualisieren des Repositories ${owner}/${repo}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRepositories,
|
||||
getRepository,
|
||||
getRepositoryBranches,
|
||||
getRepositoryCommits,
|
||||
createRepository,
|
||||
updateRepository,
|
||||
deleteRepository,
|
||||
getCurrentUser,
|
||||
testConnection,
|
||||
|
||||
@ -47,6 +47,10 @@ const NOTIFICATION_TYPES = {
|
||||
title: (data) => 'Genehmigung erforderlich',
|
||||
message: (data) => `Neue Genehmigung: "${data.proposalTitle}"`
|
||||
},
|
||||
'reminder:due': {
|
||||
title: (data) => 'Erinnerung',
|
||||
message: (data) => `${data.reminderTitle} - ${data.daysAdvance === '0' ? 'Heute' : `in ${data.daysAdvance} Tag${data.daysAdvance > 1 ? 'en' : ''}`}`
|
||||
},
|
||||
'approval:granted': {
|
||||
title: (data) => 'Genehmigung erteilt',
|
||||
message: (data) => `"${data.proposalTitle}" wurde genehmigt`
|
||||
@ -54,6 +58,10 @@ const NOTIFICATION_TYPES = {
|
||||
'approval:rejected': {
|
||||
title: (data) => 'Genehmigung abgelehnt',
|
||||
message: (data) => `"${data.proposalTitle}" wurde abgelehnt`
|
||||
},
|
||||
'knowledge:new_entry': {
|
||||
title: (data) => 'Neuer Wissenseintrag',
|
||||
message: (data) => `Neuer Eintrag: "${data.entryTitle}" in ${data.categoryName}`
|
||||
}
|
||||
};
|
||||
|
||||
@ -80,8 +88,8 @@ const notificationService = {
|
||||
const message = typeConfig.message(data);
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO notifications (user_id, type, title, message, task_id, project_id, proposal_id, actor_id, is_persistent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO notifications (user_id, type, title, message, task_id, project_id, proposal_id, actor_id, is_persistent, data)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
type,
|
||||
@ -91,7 +99,8 @@ const notificationService = {
|
||||
data.projectId || null,
|
||||
data.proposalId || null,
|
||||
data.actorId || null,
|
||||
persistent ? 1 : 0
|
||||
persistent ? 1 : 0,
|
||||
JSON.stringify(data) // Zusätzliche Daten als JSON speichern
|
||||
);
|
||||
|
||||
const notification = db.prepare(`
|
||||
@ -256,6 +265,16 @@ const notificationService = {
|
||||
* Benachrichtigung formatieren für Frontend
|
||||
*/
|
||||
formatNotification(notification) {
|
||||
// Parse zusätzliche Daten wenn vorhanden
|
||||
let additionalData = {};
|
||||
if (notification.data) {
|
||||
try {
|
||||
additionalData = JSON.parse(notification.data);
|
||||
} catch (e) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: notification.id,
|
||||
userId: notification.user_id,
|
||||
@ -270,7 +289,10 @@ const notificationService = {
|
||||
actorColor: notification.actor_color,
|
||||
isRead: notification.is_read === 1,
|
||||
isPersistent: notification.is_persistent === 1,
|
||||
createdAt: notification.created_at
|
||||
createdAt: notification.created_at,
|
||||
// Zusätzliche Daten für Knowledge-Einträge
|
||||
entryId: additionalData.entryId || null,
|
||||
categoryId: additionalData.categoryId || null
|
||||
};
|
||||
},
|
||||
|
||||
@ -284,6 +306,24 @@ const notificationService = {
|
||||
if (result) results.push(result);
|
||||
});
|
||||
return results;
|
||||
},
|
||||
|
||||
/**
|
||||
* Reminder-Benachrichtigung erstellen
|
||||
*/
|
||||
createReminderNotification(reminder, daysAdvance, io) {
|
||||
return this.create(
|
||||
reminder.created_by,
|
||||
'reminder:due',
|
||||
{
|
||||
reminderTitle: reminder.title,
|
||||
daysAdvance: daysAdvance.toString(),
|
||||
projectId: reminder.project_id,
|
||||
reminderId: reminder.id
|
||||
},
|
||||
io,
|
||||
false
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
242
backend/services/reminderService.js
Normale Datei
242
backend/services/reminderService.js
Normale Datei
@ -0,0 +1,242 @@
|
||||
/**
|
||||
* TASKMATE - Reminder Service
|
||||
* ===========================
|
||||
* Service für Erinnerungsbenachrichtigungen und Scheduling
|
||||
*/
|
||||
|
||||
const { getDb } = require('../database');
|
||||
const notificationService = require('./notificationService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class ReminderService {
|
||||
constructor(io = null) {
|
||||
this.io = io;
|
||||
this.intervalId = null;
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Reminder-Check-Service
|
||||
* Läuft alle 5 Minuten (kann für Produktion auf 1 Stunde erhöht werden)
|
||||
*/
|
||||
start() {
|
||||
if (this.isRunning) {
|
||||
logger.warn('Reminder Service ist bereits gestartet');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isRunning = true;
|
||||
|
||||
// Sofort prüfen
|
||||
this.checkDueReminders();
|
||||
|
||||
// Dann alle 5 Minuten (300000 ms)
|
||||
// In Produktion könnte das auf 1 Stunde (3600000 ms) erhöht werden
|
||||
this.intervalId = setInterval(() => {
|
||||
this.checkDueReminders();
|
||||
}, 300000); // 5 Minuten
|
||||
|
||||
logger.info('Reminder Service gestartet - prüft alle 5 Minuten');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stoppt den Reminder-Check-Service
|
||||
*/
|
||||
stop() {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
this.intervalId = null;
|
||||
}
|
||||
|
||||
this.isRunning = false;
|
||||
logger.info('Reminder Service gestoppt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft fällige Erinnerungen und sendet Benachrichtigungen
|
||||
*/
|
||||
async checkDueReminders() {
|
||||
try {
|
||||
const db = getDb();
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
|
||||
// Finde alle fälligen Benachrichtigungen die noch nicht gesendet wurden
|
||||
const dueNotifications = db.prepare(`
|
||||
SELECT
|
||||
rn.*,
|
||||
r.id as reminder_id,
|
||||
r.title as reminder_title,
|
||||
r.description as reminder_description,
|
||||
r.reminder_date,
|
||||
r.reminder_time,
|
||||
r.color,
|
||||
r.project_id,
|
||||
r.created_by,
|
||||
r.advance_days
|
||||
FROM reminder_notifications rn
|
||||
JOIN reminders r ON rn.reminder_id = r.id
|
||||
WHERE rn.notification_date <= ?
|
||||
AND rn.sent = 0
|
||||
AND r.is_active = 1
|
||||
ORDER BY rn.notification_date ASC, r.reminder_time ASC
|
||||
`).all(today);
|
||||
|
||||
if (dueNotifications.length === 0) {
|
||||
logger.debug('Keine fälligen Erinnerungen gefunden');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`${dueNotifications.length} fällige Erinnerung(en) gefunden`);
|
||||
|
||||
// Verarbeite jede fällige Benachrichtigung
|
||||
for (const notification of dueNotifications) {
|
||||
await this.processReminderNotification(notification);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Prüfen fälliger Erinnerungen:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet eine einzelne fällige Erinnerungs-Benachrichtigung
|
||||
*/
|
||||
async processReminderNotification(notification) {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
// Berechne wie viele Tage im Voraus diese Benachrichtigung ist
|
||||
const reminderDate = new Date(notification.reminder_date);
|
||||
const notificationDate = new Date(notification.notification_date);
|
||||
const daysDiff = Math.ceil((reminderDate - notificationDate) / (1000 * 60 * 60 * 24));
|
||||
|
||||
const reminder = {
|
||||
id: notification.reminder_id,
|
||||
title: notification.reminder_title,
|
||||
description: notification.reminder_description,
|
||||
project_id: notification.project_id,
|
||||
created_by: notification.created_by,
|
||||
color: notification.color
|
||||
};
|
||||
|
||||
// Erstelle Benachrichtigung
|
||||
const createdNotification = notificationService.createReminderNotification(
|
||||
reminder,
|
||||
daysDiff,
|
||||
this.io
|
||||
);
|
||||
|
||||
if (createdNotification) {
|
||||
// Markiere als gesendet
|
||||
db.prepare(`
|
||||
UPDATE reminder_notifications
|
||||
SET sent = 1
|
||||
WHERE id = ?
|
||||
`).run(notification.id);
|
||||
|
||||
logger.info(`Reminder-Benachrichtigung gesendet: "${notification.reminder_title}" (${daysDiff} Tage vorher)`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Verarbeiten der Reminder-Benachrichtigung ${notification.id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Erstellt Benachrichtigungstermine für eine neue Erinnerung
|
||||
*/
|
||||
createNotificationSchedule(reminderId, reminderDate, advanceDays) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const baseDate = new Date(reminderDate);
|
||||
|
||||
// Lösche alte Termine falls vorhanden
|
||||
db.prepare('DELETE FROM reminder_notifications WHERE reminder_id = ?').run(reminderId);
|
||||
|
||||
// Erstelle neue Termine für jeden advance day
|
||||
advanceDays.forEach(days => {
|
||||
const notificationDate = new Date(baseDate);
|
||||
notificationDate.setDate(notificationDate.getDate() - parseInt(days));
|
||||
|
||||
const notificationDateStr = notificationDate.toISOString().split('T')[0];
|
||||
|
||||
// Nur zukünftige Termine erstellen
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (notificationDateStr >= today) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO reminder_notifications (reminder_id, notification_date)
|
||||
VALUES (?, ?)
|
||||
`).run(reminderId, notificationDateStr);
|
||||
}
|
||||
});
|
||||
|
||||
logger.debug(`Benachrichtigungstermine erstellt für Reminder ${reminderId}`);
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Erstellen der Benachrichtigungstermine für Reminder ${reminderId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manuelle Prüfung für API-Endpoint
|
||||
*/
|
||||
async manualCheck() {
|
||||
logger.info('Manuelle Reminder-Prüfung ausgelöst');
|
||||
return await this.checkDueReminders();
|
||||
}
|
||||
|
||||
/**
|
||||
* Statistiken für Debugging
|
||||
*/
|
||||
getStats() {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
const stats = {
|
||||
isRunning: this.isRunning,
|
||||
activeReminders: db.prepare('SELECT COUNT(*) as count FROM reminders WHERE is_active = 1').get().count,
|
||||
pendingNotifications: db.prepare('SELECT COUNT(*) as count FROM reminder_notifications WHERE sent = 0').get().count,
|
||||
nextDueDate: db.prepare(`
|
||||
SELECT MIN(notification_date) as next_date
|
||||
FROM reminder_notifications
|
||||
WHERE sent = 0 AND notification_date >= date('now')
|
||||
`).get().next_date
|
||||
};
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Reminder-Statistiken:', error);
|
||||
return { error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket.io Instanz setzen/aktualisieren
|
||||
*/
|
||||
setSocketIO(io) {
|
||||
this.io = io;
|
||||
logger.debug('Socket.IO Instanz für Reminder Service aktualisiert');
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton Export
|
||||
let instance = null;
|
||||
|
||||
module.exports = {
|
||||
getInstance(io = null) {
|
||||
if (!instance) {
|
||||
instance = new ReminderService(io);
|
||||
} else if (io) {
|
||||
instance.setSocketIO(io);
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
|
||||
// Für Tests und Debugging
|
||||
createInstance(io = null) {
|
||||
return new ReminderService(io);
|
||||
}
|
||||
};
|
||||
@ -7,6 +7,7 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('./logger');
|
||||
const { encryptFile, decryptFile, secureDelete } = require('./encryption');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
@ -17,8 +18,9 @@ if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Backup erstellen
|
||||
* Backup erstellen (mit einfacher Verschlüsselung)
|
||||
*/
|
||||
function createBackup() {
|
||||
try {
|
||||
@ -29,12 +31,27 @@ function createBackup() {
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupName = `backup_${timestamp}.db`;
|
||||
const encryptedName = `backup_${timestamp}.db.enc`;
|
||||
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||
const encryptedPath = path.join(BACKUP_DIR, encryptedName);
|
||||
|
||||
// Datenbank kopieren
|
||||
// 1. Normales Backup erstellen (für Kompatibilität)
|
||||
fs.copyFileSync(DB_FILE, backupPath);
|
||||
|
||||
// WAL-Datei auch sichern falls vorhanden
|
||||
// 2. Verschlüsseltes Backup erstellen (zusätzlich)
|
||||
if (process.env.ENCRYPTION_KEY) {
|
||||
try {
|
||||
if (encryptFile(DB_FILE, encryptedPath)) {
|
||||
logger.info(`Verschlüsseltes Backup erstellt: ${encryptedName}`);
|
||||
} else {
|
||||
logger.warn('Verschlüsselung fehlgeschlagen, nur normales Backup erstellt');
|
||||
}
|
||||
} catch (encError) {
|
||||
logger.warn('Verschlüsselung fehlgeschlagen, nur normales Backup erstellt');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. WAL-Datei sichern falls vorhanden
|
||||
const walFile = DB_FILE + '-wal';
|
||||
if (fs.existsSync(walFile)) {
|
||||
fs.copyFileSync(walFile, backupPath + '-wal');
|
||||
@ -53,12 +70,12 @@ function createBackup() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Alte Backups löschen
|
||||
* Alte Backups löschen (verschlüsselte)
|
||||
*/
|
||||
function cleanupOldBackups(keepCount = 30) {
|
||||
try {
|
||||
const files = fs.readdirSync(BACKUP_DIR)
|
||||
.filter(f => f.startsWith('backup_') && f.endsWith('.db'))
|
||||
.filter(f => f.startsWith('backup_') && f.endsWith('.db.enc'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
@ -66,15 +83,15 @@ function cleanupOldBackups(keepCount = 30) {
|
||||
|
||||
toDelete.forEach(file => {
|
||||
const filePath = path.join(BACKUP_DIR, file);
|
||||
fs.unlinkSync(filePath);
|
||||
secureDelete(filePath);
|
||||
|
||||
// WAL-Datei auch löschen falls vorhanden
|
||||
// Verschlüsselte WAL-Datei auch löschen falls vorhanden
|
||||
const walPath = filePath + '-wal';
|
||||
if (fs.existsSync(walPath)) {
|
||||
fs.unlinkSync(walPath);
|
||||
secureDelete(walPath);
|
||||
}
|
||||
|
||||
logger.info(`Altes Backup gelöscht: ${file}`);
|
||||
logger.info(`Altes Backup sicher gelöscht: ${file}`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aufräumen alter Backups:', { error: error.message });
|
||||
@ -82,32 +99,50 @@ function cleanupOldBackups(keepCount = 30) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup wiederherstellen
|
||||
* Backup wiederherstellen (entschlüsselt)
|
||||
*/
|
||||
function restoreBackup(backupName) {
|
||||
try {
|
||||
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||
const encryptedBackupPath = path.join(BACKUP_DIR, backupName);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
if (!fs.existsSync(encryptedBackupPath)) {
|
||||
throw new Error(`Backup nicht gefunden: ${backupName}`);
|
||||
}
|
||||
|
||||
// Aktuelles DB sichern bevor überschrieben wird
|
||||
// Aktuelles DB verschlüsselt sichern bevor überschrieben wird
|
||||
if (fs.existsSync(DB_FILE)) {
|
||||
const safetyBackup = DB_FILE + '.before-restore';
|
||||
fs.copyFileSync(DB_FILE, safetyBackup);
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const safetyBackupPath = DB_FILE + `.before-restore-${timestamp}.enc`;
|
||||
if (!encryptFile(DB_FILE, safetyBackupPath)) {
|
||||
logger.warn('Sicherheitsbackup vor Wiederherstellung fehlgeschlagen');
|
||||
}
|
||||
}
|
||||
|
||||
// Backup wiederherstellen
|
||||
fs.copyFileSync(backupPath, DB_FILE);
|
||||
// Temporäre entschlüsselte Datei
|
||||
const tempRestorePath = path.join(BACKUP_DIR, `temp_restore_${Date.now()}.db`);
|
||||
|
||||
// Backup entschlüsseln
|
||||
if (!decryptFile(encryptedBackupPath, tempRestorePath)) {
|
||||
throw new Error('Backup-Entschlüsselung fehlgeschlagen');
|
||||
}
|
||||
|
||||
// Entschlüsselte DB kopieren
|
||||
fs.copyFileSync(tempRestorePath, DB_FILE);
|
||||
|
||||
// WAL-Datei auch wiederherstellen falls vorhanden
|
||||
const walBackup = backupPath + '-wal';
|
||||
if (fs.existsSync(walBackup)) {
|
||||
fs.copyFileSync(walBackup, DB_FILE + '-wal');
|
||||
const encryptedWalBackup = encryptedBackupPath + '-wal';
|
||||
if (fs.existsSync(encryptedWalBackup)) {
|
||||
const tempWalPath = tempRestorePath + '-wal';
|
||||
if (decryptFile(encryptedWalBackup, tempWalPath)) {
|
||||
fs.copyFileSync(tempWalPath, DB_FILE + '-wal');
|
||||
secureDelete(tempWalPath);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Backup wiederhergestellt: ${backupName}`);
|
||||
// Temporäre entschlüsselte Dateien sicher löschen
|
||||
secureDelete(tempRestorePath);
|
||||
|
||||
logger.info(`Verschlüsseltes Backup wiederhergestellt: ${backupName}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Restore-Fehler:', { error: error.message });
|
||||
@ -116,12 +151,12 @@ function restoreBackup(backupName) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste aller Backups
|
||||
* Liste aller verschlüsselten Backups
|
||||
*/
|
||||
function listBackups() {
|
||||
try {
|
||||
const files = fs.readdirSync(BACKUP_DIR)
|
||||
.filter(f => f.startsWith('backup_') && f.endsWith('.db'))
|
||||
.filter(f => f.startsWith('backup_') && f.endsWith('.db.enc'))
|
||||
.map(f => {
|
||||
const filePath = path.join(BACKUP_DIR, f);
|
||||
const stats = fs.statSync(filePath);
|
||||
|
||||
237
backend/utils/encryption.js
Normale Datei
237
backend/utils/encryption.js
Normale Datei
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* TASKMATE - Encryption Utilities
|
||||
* ================================
|
||||
* Verschlüsselung für Backups und sensitive Daten
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { promisify } = require('util');
|
||||
const logger = require('./logger');
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
const KEY_LENGTH = 32; // 256 bits
|
||||
const IV_LENGTH = 16; // 128 bits
|
||||
const SALT_LENGTH = 32;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
/**
|
||||
* Encryption Key aus Umgebung oder generiert
|
||||
*/
|
||||
function getEncryptionKey() {
|
||||
let key = process.env.ENCRYPTION_KEY;
|
||||
|
||||
if (!key) {
|
||||
// Generiere neuen Key falls nicht vorhanden
|
||||
key = crypto.randomBytes(KEY_LENGTH).toString('hex');
|
||||
logger.warn('Encryption Key wurde automatisch generiert. Speichere ihn in der .env: ENCRYPTION_KEY=' + key);
|
||||
return Buffer.from(key, 'hex');
|
||||
}
|
||||
|
||||
// Validiere Key-Length
|
||||
if (key.length !== KEY_LENGTH * 2) { // Hex-String ist doppelt so lang
|
||||
throw new Error(`Encryption Key muss ${KEY_LENGTH * 2} Hex-Zeichen haben`);
|
||||
}
|
||||
|
||||
return Buffer.from(key, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Key aus Passwort ableiten (PBKDF2)
|
||||
*/
|
||||
function deriveKeyFromPassword(password, salt) {
|
||||
return crypto.pbkdf2Sync(password, salt, 100000, KEY_LENGTH, 'sha256');
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei verschlüsseln
|
||||
*/
|
||||
function encryptFile(inputPath, outputPath, password = null) {
|
||||
try {
|
||||
const data = fs.readFileSync(inputPath);
|
||||
|
||||
// Salt und IV generieren
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
// Key ableiten
|
||||
const key = password
|
||||
? deriveKeyFromPassword(password, salt)
|
||||
: getEncryptionKey();
|
||||
|
||||
// Verschlüsselung
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
|
||||
|
||||
// Header + Salt + IV + verschlüsselte Daten
|
||||
const header = Buffer.from('TMENC001', 'ascii'); // TaskMate Encryption v1
|
||||
const result = Buffer.concat([
|
||||
header,
|
||||
salt,
|
||||
iv,
|
||||
encrypted
|
||||
]);
|
||||
|
||||
fs.writeFileSync(outputPath, result);
|
||||
logger.info(`Datei verschlüsselt: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Verschlüsselung fehlgeschlagen: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei entschlüsseln
|
||||
*/
|
||||
function decryptFile(inputPath, outputPath, password = null) {
|
||||
try {
|
||||
const encryptedData = fs.readFileSync(inputPath);
|
||||
|
||||
// Header prüfen
|
||||
const header = encryptedData.subarray(0, 8);
|
||||
if (header.toString('ascii') !== 'TMENC001') {
|
||||
throw new Error('Ungültiges verschlüsseltes Datei-Format');
|
||||
}
|
||||
|
||||
// Komponenten extrahieren
|
||||
let offset = 8;
|
||||
const salt = encryptedData.subarray(offset, offset + SALT_LENGTH);
|
||||
offset += SALT_LENGTH;
|
||||
|
||||
const iv = encryptedData.subarray(offset, offset + IV_LENGTH);
|
||||
offset += IV_LENGTH;
|
||||
|
||||
const encrypted = encryptedData.subarray(offset);
|
||||
|
||||
// Key ableiten
|
||||
const key = password
|
||||
? deriveKeyFromPassword(password, salt)
|
||||
: getEncryptionKey();
|
||||
|
||||
// Entschlüsselung
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
fs.writeFileSync(outputPath, decrypted);
|
||||
logger.info(`Datei entschlüsselt: ${path.basename(inputPath)} -> ${path.basename(outputPath)}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Entschlüsselung fehlgeschlagen: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String verschlüsseln (für Passwörter etc.)
|
||||
*/
|
||||
function encryptString(plaintext, password = null) {
|
||||
try {
|
||||
const salt = crypto.randomBytes(SALT_LENGTH);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
|
||||
const key = password
|
||||
? deriveKeyFromPassword(password, salt)
|
||||
: getEncryptionKey();
|
||||
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const encrypted = Buffer.concat([
|
||||
cipher.update(Buffer.from(plaintext, 'utf8')),
|
||||
cipher.final()
|
||||
]);
|
||||
|
||||
// Base64 kodiert zurückgeben
|
||||
const result = Buffer.concat([salt, iv, encrypted]);
|
||||
return result.toString('base64');
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`String-Verschlüsselung fehlgeschlagen: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String entschlüsseln
|
||||
*/
|
||||
function decryptString(encryptedString, password = null) {
|
||||
try {
|
||||
const data = Buffer.from(encryptedString, 'base64');
|
||||
|
||||
let offset = 0;
|
||||
const salt = data.subarray(offset, offset + SALT_LENGTH);
|
||||
offset += SALT_LENGTH;
|
||||
|
||||
const iv = data.subarray(offset, offset + IV_LENGTH);
|
||||
offset += IV_LENGTH;
|
||||
|
||||
const encrypted = data.subarray(offset);
|
||||
|
||||
const key = password
|
||||
? deriveKeyFromPassword(password, salt)
|
||||
: getEncryptionKey();
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const decrypted = Buffer.concat([
|
||||
decipher.update(encrypted),
|
||||
decipher.final()
|
||||
]);
|
||||
|
||||
return decrypted.toString('utf8');
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`String-Entschlüsselung fehlgeschlagen: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sicheres Löschen einer Datei (Überschreiben)
|
||||
*/
|
||||
function secureDelete(filePath) {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
const fileSize = stats.size;
|
||||
|
||||
// Datei mehrfach mit Zufallsdaten überschreiben
|
||||
const fd = fs.openSync(filePath, 'r+');
|
||||
|
||||
for (let pass = 0; pass < 3; pass++) {
|
||||
const randomData = crypto.randomBytes(fileSize);
|
||||
fs.writeSync(fd, randomData, 0, fileSize, 0);
|
||||
fs.fsyncSync(fd);
|
||||
}
|
||||
|
||||
fs.closeSync(fd);
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
logger.info(`Datei sicher gelöscht: ${path.basename(filePath)}`);
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Sicheres Löschen fehlgeschlagen: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encryptFile,
|
||||
decryptFile,
|
||||
encryptString,
|
||||
decryptString,
|
||||
secureDelete,
|
||||
getEncryptionKey
|
||||
};
|
||||
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
51
create-placeholder-icons.sh
Ausführbare Datei
51
create-placeholder-icons.sh
Ausführbare Datei
@ -0,0 +1,51 @@
|
||||
#!/bin/bash
|
||||
# Erstellt Platzhalter-Icons für TaskMate PWA
|
||||
|
||||
cd /home/claude-dev/TaskMate/frontend/assets/icons/
|
||||
|
||||
# Funktion zum Erstellen eines PNG aus SVG mit ImageMagick
|
||||
create_icon() {
|
||||
size=$1
|
||||
echo "Erstelle icon-${size}x${size}.png..."
|
||||
|
||||
# Mit rsvg-convert (falls verfügbar)
|
||||
if command -v rsvg-convert &> /dev/null; then
|
||||
rsvg-convert -w $size -h $size taskmate-logo.svg -o icon-${size}x${size}.png
|
||||
rsvg-convert -w $size -h $size taskmate-logo.svg -o icon-maskable-${size}x${size}.png
|
||||
# Mit convert/ImageMagick (falls verfügbar)
|
||||
elif command -v convert &> /dev/null; then
|
||||
convert -background transparent -resize ${size}x${size} taskmate-logo.svg icon-${size}x${size}.png
|
||||
convert -background transparent -resize ${size}x${size} taskmate-logo.svg icon-maskable-${size}x${size}.png
|
||||
else
|
||||
echo "Weder rsvg-convert noch ImageMagick gefunden!"
|
||||
echo "Installieren Sie eines davon mit:"
|
||||
echo " sudo apt-get install librsvg2-bin"
|
||||
echo " oder"
|
||||
echo " sudo apt-get install imagemagick"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Alle benötigten Größen
|
||||
sizes=(48 72 96 128 144 152 192 384 512)
|
||||
|
||||
# Icons erstellen
|
||||
for size in "${sizes[@]}"; do
|
||||
create_icon $size
|
||||
done
|
||||
|
||||
# Zusätzliche Icons
|
||||
if command -v rsvg-convert &> /dev/null; then
|
||||
echo "Erstelle Shortcut-Icons..."
|
||||
rsvg-convert -w 96 -h 96 taskmate-logo.svg -o add-task-96x96.png
|
||||
rsvg-convert -w 96 -h 96 taskmate-logo.svg -o calendar-96x96.png
|
||||
elif command -v convert &> /dev/null; then
|
||||
echo "Erstelle Shortcut-Icons..."
|
||||
convert -background transparent -resize 96x96 taskmate-logo.svg add-task-96x96.png
|
||||
convert -background transparent -resize 96x96 taskmate-logo.svg calendar-96x96.png
|
||||
fi
|
||||
|
||||
echo "Fertig! Icons wurden erstellt."
|
||||
echo ""
|
||||
echo "Nächster Schritt: Icons in Docker Container kopieren:"
|
||||
echo "docker cp *.png taskmate:/app/public/assets/icons/"
|
||||
BIN
data/taskmate.db
BIN
data/taskmate.db
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
Binäre Datei nicht angezeigt.
@ -4,16 +4,13 @@ services:
|
||||
container_name: taskmate
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${PORT:-3000}:3000"
|
||||
- "127.0.0.1:${PORT:-3001}:3000" # Nur localhost, Zugriff über Nginx
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./backups:/app/backups
|
||||
- ./logs:/app/logs
|
||||
- ./uploads:/app/uploads
|
||||
# Laufwerke für Git-Repositories (bei Bedarf weitere hinzufügen)
|
||||
- C:/:/mnt/c
|
||||
# - D:/:/mnt/d # Deaktiviert - Laufwerk nicht verfügbar
|
||||
# - E:/:/mnt/e # Deaktiviert - Laufwerk nicht verfügbar
|
||||
- .:/app/taskmate-source
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- JWT_SECRET=${JWT_SECRET}
|
||||
@ -34,6 +31,7 @@ services:
|
||||
- USER2_PASSWORD=${USER2_PASSWORD:-changeme456}
|
||||
- USER2_DISPLAYNAME=${USER2_DISPLAYNAME:-Benutzer 2}
|
||||
- USER2_COLOR=${USER2_COLOR:-#FF9500}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
|
||||
43
fix_passwords.js
Normale Datei
43
fix_passwords.js
Normale Datei
@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Password Fix Script
|
||||
* Setzt die Passwort-Hashes für alle Benutzer zurück
|
||||
*/
|
||||
|
||||
const bcrypt = require('bcrypt');
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
|
||||
async function fixPasswords() {
|
||||
const db = new Database(path.join(__dirname, 'data/taskmate.db'));
|
||||
|
||||
console.log('Setze Passwort-Hashes zurück...');
|
||||
|
||||
// Standard-Passwörter
|
||||
const passwords = {
|
||||
'admin': 'admin123',
|
||||
'hendrik_gebhardt@gmx.de': 'Hzfne313!fdEF34',
|
||||
'momohomma@googlemail.com': 'Hzfne313!fdEF34'
|
||||
};
|
||||
|
||||
for (const [username, password] of Object.entries(passwords)) {
|
||||
const hash = await bcrypt.hash(password, 12);
|
||||
|
||||
// Update basierend auf E-Mail oder Username
|
||||
const result = db.prepare(`
|
||||
UPDATE users
|
||||
SET password_hash = ?, failed_attempts = 0, locked_until = NULL
|
||||
WHERE email = ? OR username = ?
|
||||
`).run(hash, username, username);
|
||||
|
||||
if (result.changes > 0) {
|
||||
console.log(`✅ Passwort für ${username} aktualisiert`);
|
||||
} else {
|
||||
console.log(`❌ Benutzer ${username} nicht gefunden`);
|
||||
}
|
||||
}
|
||||
|
||||
db.close();
|
||||
console.log('Passwort-Fix abgeschlossen!');
|
||||
}
|
||||
|
||||
fixPasswords().catch(console.error);
|
||||
10
frontend/.well-known/assetlinks.json
Normale Datei
10
frontend/.well-known/assetlinks.json
Normale Datei
@ -0,0 +1,10 @@
|
||||
[{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "de.aegissight.taskmate",
|
||||
"sha256_cert_fingerprints": [
|
||||
"TO_BE_REPLACED_WITH_YOUR_APP_SIGNING_KEY_FINGERPRINT"
|
||||
]
|
||||
}
|
||||
}]
|
||||
BIN
frontend/assets/icons/add-task-96x96.png
Normale Datei
BIN
frontend/assets/icons/add-task-96x96.png
Normale Datei
Binäre Datei nicht angezeigt.
|
Nachher Breite: | Höhe: | Größe: 3.9 KiB |
BIN
frontend/assets/icons/android-launchericon-144-144.png
Normale Datei
BIN
frontend/assets/icons/android-launchericon-144-144.png
Normale Datei
Binäre Datei nicht angezeigt.
|
Nachher Breite: | Höhe: | Größe: 5.7 KiB |
Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen
In neuem Issue referenzieren
Einen Benutzer sperren