Dieser Commit ist enthalten in:
Claude Project Manager
2025-12-28 21:36:45 +00:00
Commit ab1e5be9a9
146 geänderte Dateien mit 65525 neuen und 0 gelöschten Zeilen

29
.claude/settings.local.json Normale Datei
Datei anzeigen

@ -0,0 +1,29 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(powershell:*)",
"Bash(node:*)",
"Bash(if exist \"data\\\\taskmate.db\" del \"data\\\\taskmate.db\")",
"Bash(if exist \"data\\\\taskmate.db-wal\" del \"data\\\\taskmate.db-wal\")",
"Bash(if exist \"data\\\\taskmate.db-shm\" del \"data\\\\taskmate.db-shm\")",
"Bash(docker exec:*)",
"Bash(findstr:*)",
"Bash(docker compose:*)",
"Bash(timeout /t 3 /nobreak)",
"Bash(docker logs:*)",
"Bash(timeout /t 15 /nobreak)",
"Bash(curl:*)",
"Bash(dir:*)",
"Bash(start chrome:*)",
"Bash(if exist \"C:\\\\Users\\\\hendr\\\\Desktop\\\\IntelSight\\\\Projektablage\\\\TaskMate\\\\data\\\\taskmate.db\" del \"C:\\\\Users\\\\hendr\\\\Desktop\\\\IntelSight\\\\Projektablage\\\\TaskMate\\\\data\\\\taskmate.db\")",
"Bash(if exist \"C:\\\\Users\\\\hendr\\\\Desktop\\\\IntelSight\\\\Projektablage\\\\TaskMate\\\\data\\\\taskmate.db-wal\" del \"C:\\\\Users\\\\hendr\\\\Desktop\\\\IntelSight\\\\Projektablage\\\\TaskMate\\\\data\\\\taskmate.db-wal\")",
"Bash(if exist \"C:\\\\Users\\\\hendr\\\\Desktop\\\\IntelSight\\\\Projektablage\\\\TaskMate\\\\data\\\\taskmate.db-shm\" del \"C:\\\\Users\\\\hendr\\\\Desktop\\\\IntelSight\\\\Projektablage\\\\TaskMate\\\\data\\\\taskmate.db-shm\")",
"Bash(cat:*)",
"Bash(timeout /t 5 /nobreak)",
"Bash(start chrome:*)",
"WebSearch",
"Bash(wc:*)"
]
}
}

53
.env Normale Datei
Datei anzeigen

@ -0,0 +1,53 @@
# =============================================================================
# TASKMATE - Umgebungsvariablen
# =============================================================================
# -----------------------------------------------------------------------------
# SERVER
# -----------------------------------------------------------------------------
PORT=3000
# -----------------------------------------------------------------------------
# SICHERHEIT
# -----------------------------------------------------------------------------
JWT_SECRET=c0c02ffaa6209fac125d90c3c69025abb0bc08b8d8d333b71ccf1c6e875a004e
# Session-Timeout in Minuten (automatischer Logout bei Inaktivität)
SESSION_TIMEOUT=30
# Maximale fehlgeschlagene Login-Versuche vor Sperrung
MAX_LOGIN_ATTEMPTS=5
# Dauer der Sperrung in Minuten nach zu vielen Fehlversuchen
LOCKOUT_DURATION_MINUTES=15
# -----------------------------------------------------------------------------
# DATEIEN
# -----------------------------------------------------------------------------
# Maximale Dateigröße für Uploads in MB
MAX_FILE_SIZE_MB=15
# -----------------------------------------------------------------------------
# BACKUP
# -----------------------------------------------------------------------------
BACKUP_ENABLED=true
BACKUP_INTERVAL_HOURS=24
# -----------------------------------------------------------------------------
# BENUTZER (werden beim ersten Start angelegt)
# -----------------------------------------------------------------------------
USER1_USERNAME=HG
USER1_PASSWORD=Hzfne313!fdEF34
USER1_DISPLAYNAME=User 1
USER1_COLOR=#00D4FF
USER2_USERNAME=MH
USER2_PASSWORD=SICHERES_PASSWORT_HIER
USER2_DISPLAYNAME=User 2
USER2_COLOR=#FF9500
# -----------------------------------------------------------------------------
# GITEA-INTEGRATION
# -----------------------------------------------------------------------------
GITEA_URL=https://gitea-undso.aegis-sight.de
GITEA_TOKEN=8d76ec66b3e1f02e9b1e4848d8b15e0cdffe48df

542
ANWENDUNGSBESCHREIBUNG.txt Normale Datei
Datei anzeigen

@ -0,0 +1,542 @@
================================================================================
TASKMATE - ANWENDUNGSBESCHREIBUNG
================================================================================
TaskMate ist eine webbasierte Aufgabenverwaltung im Kanban-Stil, die Teams
dabei unterstuetzt, Projekte und Aufgaben effizient zu organisieren und zu
verfolgen.
================================================================================
INHALTSVERZEICHNIS
================================================================================
1. Erste Schritte
2. Hauptansichten
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
================================================================================
1. ERSTE SCHRITTE
================================================================================
ANMELDUNG
---------
1. Öffnen Sie TaskMate in Ihrem Browser
2. Geben Sie Ihre E-Mail-Adresse und Ihr Passwort ein
3. Klicken Sie auf "Anmelden"
HINWEIS: Der Admin-Benutzer meldet sich mit "admin" (nicht E-Mail) an.
Es gibt zwei Benutzertypen:
REGULÄRE BENUTZER
- Anmeldung mit E-Mail-Adresse und Passwort
- Zugang zum Kanban-Board, Kalender und Genehmigung
- Können Aufgaben erstellen, bearbeiten und verwalten
- Können Vorschläge erstellen und abstimmen
ADMINISTRATOREN
- Zugang NUR zur Benutzerverwaltung
- Können Benutzer erstellen, bearbeiten und löschen
- Können Berechtigungen vergeben
- Kein Zugang zur regulären App (Board, Kalender, etc.)
Standard-Admin-Zugangsdaten:
- Benutzername: admin
- Passwort: !1Data123
Nach der Anmeldung als regulärer Benutzer sehen Sie das Kanban-Board.
DIE OBERFLAECHE
---------------
Die Oberflaeche besteht aus:
+-------------------------------------------------------------------------+
| Logo | Projekt-Auswahl | Board | Kalender | Genehmigung | Suche | User |
+-------------------------------------------------------------------------+
| Filterleiste: Benutzer | Prioritaet | Labels | Faelligkeit |
+------------------------------------------------------------------+
| Statistik: Offen | In Arbeit | Erledigt | Ueberfaellig |
+------------------------------------------------------------------+
| |
| Spalte 1 Spalte 2 Spalte 3 + Spalte |
| +---------+ +---------+ +---------+ |
| | Aufgabe | | Aufgabe | | Aufgabe | |
| +---------+ +---------+ +---------+ |
| | Aufgabe | | Aufgabe | |
| +---------+ +---------+ |
| + Aufgabe |
| |
+------------------------------------------------------------------+
================================================================================
2. HAUPTANSICHTEN
================================================================================
BOARD-ANSICHT (Standard)
------------------------
Das Kanban-Board zeigt Aufgaben in Spalten organisiert. Typische Spalten sind:
- "Offen" oder "To Do"
- "In Bearbeitung"
- "Erledigt"
Sie koennen Aufgaben per Drag & Drop zwischen Spalten verschieben.
KALENDER-ANSICHT
----------------
Wechseln Sie zur Kalenderansicht über den Tab "Kalender" in der Kopfzeile.
Der Kalender zeigt Aufgaben basierend auf ihrem Start- und Enddatum:
- Monatsansicht: Überblick über den gesamten Monat
- Wochenansicht: Detaillierte Ansicht einer Woche
Funktionen:
- Pfeile links/rechts: Zwischen Monaten/Wochen navigieren
- "Heute"-Button: Zum aktuellen Datum springen
- "Offene Aufgaben anzeigen": Ein-/Ausblenden offener Aufgaben
- "Erledigte Aufgaben anzeigen": Ein-/Ausblenden erledigter Aufgaben
Aufgaben werden in der Farbe ihrer Spalte angezeigt.
GENEHMIGUNG-ANSICHT
-------------------
Wechseln Sie zur Genehmigung über den Tab "Genehmigung" in der Kopfzeile.
Hier können Teammitglieder Vorschläge einreichen und abstimmen.
Details siehe Abschnitt 6.
================================================================================
3. AUFGABEN VERWALTEN
================================================================================
NEUE AUFGABE ERSTELLEN
----------------------
1. Klicken Sie auf "+ Aufgabe" am unteren Rand einer Spalte
2. Geben Sie den Titel ein
3. Fuellen Sie die gewuenschten Felder aus
4. Klicken Sie auf "Speichern"
Alternativ: Schnell-Hinzufuegen mit Klick auf "+ Aufgabe"
AUFGABE BEARBEITEN
------------------
Klicken Sie auf eine Aufgabe, um das Detail-Modal zu oeffnen.
Verfuegbare Felder:
- Titel: Name der Aufgabe (Pflichtfeld)
- Beschreibung: Ausfuehrliche Beschreibung (unterstuetzt Markdown)
- Status: In welcher Spalte befindet sich die Aufgabe
- Prioritaet: Niedrig, Mittel oder Hoch
- Zustaendig: Wer ist fuer die Aufgabe verantwortlich
- Startdatum: Wann beginnt die Aufgabe
- Enddatum: Bis wann muss die Aufgabe erledigt sein
- Zeitschaetzung: Geschaetzter Aufwand in Stunden und Minuten
- Labels: Farbige Tags zur Kategorisierung
WICHTIG: Aenderungen werden automatisch gespeichert (Auto-Save).
CHECKLISTE / UNTERAUFGABEN
--------------------------
Unterteilen Sie grosse Aufgaben in kleinere Schritte:
1. Scrollen Sie im Aufgaben-Modal zu "Checkliste"
2. Geben Sie eine Unteraufgabe in das Textfeld ein
3. Klicken Sie auf "Hinzufuegen"
4. Haken Sie erledigte Punkte ab
Der Fortschrittsbalken zeigt, wie viele Unteraufgaben erledigt sind.
LINKS HINZUFUEGEN
-----------------
Verknuepfen Sie relevante Webseiten:
1. Scrollen Sie zu "Links"
2. Geben Sie optional einen Titel ein
3. Fuegen Sie die URL ein (z.B. "google.de" wird automatisch zu "https://google.de")
4. Klicken Sie auf "Hinzufuegen"
DATEIEN ANHAENGEN
-----------------
Fuegen Sie Dokumente, Bilder oder andere Dateien hinzu:
1. Scrollen Sie zu "Anhaenge"
2. Ziehen Sie Dateien in den Upload-Bereich ODER
3. Klicken Sie auf "auswaehlen" um Dateien zu suchen
Beschraenkungen:
- Maximale Dateigroesse: 15 MB pro Datei
- Bilder werden mit Vorschau angezeigt
KOMMENTARE
----------
Kommunizieren Sie mit Teammitgliedern direkt in der Aufgabe:
1. Scrollen Sie zu "Kommentare"
2. Schreiben Sie Ihren Kommentar
3. Erwaehnen Sie Benutzer mit @Benutzername
4. Klicken Sie auf "Senden"
HISTORIE
--------
Jede Aenderung an einer Aufgabe wird protokolliert:
- Wer hat was geaendert
- Wann wurde die Aenderung vorgenommen
- Was war der alte/neue Wert
AUFGABEN-AKTIONEN
-----------------
Im unteren Bereich des Aufgaben-Modals finden Sie:
- Duplizieren: Erstellt eine Kopie der Aufgabe
- Archivieren: Verschiebt die Aufgabe ins Archiv
- Loeschen: Entfernt die Aufgabe dauerhaft (mit Bestaetigung)
- Wiederherstellen: Holt archivierte Aufgaben zurueck
DRAG & DROP
-----------
Verschieben Sie Aufgaben durch Ziehen und Ablegen:
1. Klicken und halten Sie eine Aufgabenkarte
2. Ziehen Sie sie zur gewuenschten Spalte oder Position
3. Lassen Sie los, um die Aufgabe abzulegen
Die Reihenfolge der Aufgaben wird gespeichert:
1. Manuelle Position (Drag & Drop)
2. Prioritaet (Hoch vor Mittel vor Niedrig)
3. Erstellungsdatum (aeltere zuerst)
================================================================================
4. PROJEKTE UND SPALTEN
================================================================================
PROJEKTE
--------
Organisieren Sie Ihre Arbeit in separate Projekte.
Neues Projekt erstellen:
1. Klicken Sie auf das "+" Symbol neben der Projektauswahl
2. Geben Sie einen Namen und optionale Beschreibung ein
3. Klicken Sie auf "Speichern"
Projekt wechseln:
- Waehlen Sie ein Projekt aus dem Dropdown-Menu
Projekt bearbeiten/loeschen:
1. Klicken Sie auf das Stift-Symbol neben der Projektauswahl
2. Aendern Sie Name/Beschreibung ODER
3. Klicken Sie auf "Projekt loeschen"
SPALTEN
-------
Passen Sie die Spalten an Ihren Workflow an.
Neue Spalte erstellen:
1. Klicken Sie auf "+ Spalte hinzufuegen" rechts neben den Spalten
2. Geben Sie einen Namen ein
3. Waehlen Sie optional eine Farbe
4. Klicken Sie auf "Speichern"
Spalte bearbeiten:
1. Klicken Sie auf den Spaltentitel
2. Aendern Sie Name oder Farbe
3. Speichern Sie die Aenderungen
Spalte loeschen:
- Im Bearbeitungs-Dialog auf "Loeschen" klicken
- ACHTUNG: Alle Aufgaben in der Spalte werden ebenfalls geloescht!
Spalten verschieben:
- Ziehen Sie den Spaltentitel an die gewuenschte Position
================================================================================
5. KALENDERANSICHT
================================================================================
NAVIGATION
----------
- Pfeile: Vorheriger/Naechster Monat bzw. Woche
- "Heute": Springt zum aktuellen Datum
- Monat/Woche: Wechselt zwischen Ansichten
AUFGABEN IM KALENDER
--------------------
Aufgaben werden als farbige Balken angezeigt:
- Farbe entspricht der Spaltenfarbe
- Mehrtaegige Aufgaben werden als durchgehender Balken dargestellt
- Benutzer-Initialen zeigen die Zuweisung
Klicken Sie auf eine Aufgabe, um sie zu bearbeiten.
FILTER
------
- "Offene Aufgaben anzeigen": Zeigt/versteckt nicht erledigte Aufgaben
- "Erledigte Aufgaben anzeigen": Zeigt/versteckt abgeschlossene Aufgaben
================================================================================
6. GENEHMIGUNG (VORSCHLÄGE)
================================================================================
Der Genehmigung-Bereich ermöglicht es Teammitgliedern, Vorschläge einzureichen,
die von anderen bewertet und genehmigt werden können.
VORSCHLÄGE ANZEIGEN
-------------------
1. Klicken Sie auf "Genehmigung" in der Navigationsleiste
2. Alle Vorschläge werden als Karten angezeigt
3. Genehmigte Vorschläge sind grün markiert
SORTIERUNG
----------
Sortieren Sie Vorschläge nach:
- Nach Votes: Beliebteste zuerst
- Nach Datum: Neueste zuerst
- Alphabetisch: Nach Titel
NEUEN VORSCHLAG ERSTELLEN
-------------------------
1. Klicken Sie auf "Neuer Vorschlag"
2. Geben Sie einen Titel ein (Pflichtfeld)
3. Fügen Sie optional eine Beschreibung hinzu
4. Klicken Sie auf "Erstellen"
ABSTIMMEN (VOTING)
------------------
- Klicken Sie auf den Daumen-hoch-Button, um für einen Vorschlag zu stimmen
- Erneutes Klicken entfernt Ihre Stimme
- Sie können nicht für eigene Vorschläge stimmen
- Die Anzahl der Stimmen wird neben dem Button angezeigt
GENEHMIGEN
----------
Benutzer mit der Berechtigung "Genehmigung" können Vorschläge genehmigen:
1. Aktivieren Sie die Checkbox "Genehmigt" beim Vorschlag
2. Der Vorschlag wird grün markiert
3. Es wird angezeigt, wer wann genehmigt hat
VORSCHLAG LÖSCHEN
-----------------
- Eigene Vorschläge können jederzeit gelöscht werden
- Benutzer mit Genehmigungsberechtigung können alle Vorschläge löschen
================================================================================
7. ARCHIV-FUNKTION
================================================================================
Das Archiv speichert Aufgaben, die Sie nicht mehr aktiv benoetigen,
aber nicht loeschen moechten.
ARCHIV OEFFNEN
--------------
1. Klicken Sie auf "Archiv anzeigen" in der Filterleiste
2. Das Archiv-Modal zeigt alle archivierten Aufgaben
FUNKTIONEN IM ARCHIV
--------------------
- Aufgabe anzeigen: Klicken Sie auf die Aufgabe
- Wiederherstellen: Stellt die Aufgabe in der urspruenglichen Spalte wieder her
- Loeschen: Entfernt die Aufgabe dauerhaft
AUFGABE ARCHIVIEREN
-------------------
1. Oeffnen Sie die Aufgabe
2. Klicken Sie auf "Archivieren" im unteren Bereich
================================================================================
8. EINSTELLUNGEN
================================================================================
Oeffnen Sie die Einstellungen ueber das Benutzer-Menu (oben rechts).
PERSOENLICHE FARBE
------------------
Waehlen Sie Ihre Farbe fuer Aufgaben und Kalender:
1. Klicken Sie auf eine Preset-Farbe ODER
2. Nutzen Sie "Eigene Farbe" fuer individuelle Farbwahl
3. Die aktuelle Farbe wird als Farbblock und Hex-Code angezeigt
4. Die Vorschau zeigt Ihre Initialen in der gewaehlten Farbe
Die Farbe wird automatisch gespeichert und bleibt nach dem Neuladen erhalten.
PASSWORT AENDERN
----------------
1. Geben Sie Ihr aktuelles Passwort ein
2. Geben Sie das neue Passwort ein (mindestens 8 Zeichen)
3. Bestaetigen Sie das neue Passwort
4. Klicken Sie auf "Passwort aendern"
================================================================================
9. BENUTZERVERWALTUNG (ADMIN)
================================================================================
Die Benutzerverwaltung ist nur für Administratoren zugänglich und ermöglicht
die Verwaltung aller Benutzerkonten.
ZUGANG
------
Melden Sie sich mit einem Admin-Account an. Sie werden automatisch zur
Benutzerverwaltung weitergeleitet (kein Zugang zur regulären App).
BENUTZERÜBERSICHT
-----------------
Die Übersicht zeigt alle Benutzer mit:
- Avatar (Initiale in Benutzerfarbe)
- Name, Kürzel (@XX) und E-Mail-Adresse
- Rolle (Admin oder Benutzer)
- Berechtigungen (z.B. "Genehmigung")
- Status (Aktiv oder Gesperrt)
NEUEN BENUTZER ANLEGEN
----------------------
1. Klicken Sie auf "Neuer Benutzer"
2. Füllen Sie die Felder aus:
- Kürzel (2 Buchstaben, z.B. HG - wird bei Aufgaben und Kommentaren angezeigt)
- Anzeigename (vollständiger Name)
- E-Mail-Adresse (für die Anmeldung)
- Passwort (wird automatisch generiert mit 10 Zeichen inkl. Sonderzeichen)
- Rolle (Benutzer oder Administrator)
- Berechtigungen (nur für reguläre Benutzer)
3. Klicken Sie auf "Speichern"
4. Teilen Sie dem Benutzer das angezeigte Passwort mit
HINWEIS: Das Passwort kann vom Benutzer später in den Einstellungen geändert werden.
BENUTZER BEARBEITEN
-------------------
1. Klicken Sie auf das Bearbeiten-Symbol beim Benutzer
2. Ändern Sie die gewünschten Felder (Kürzel kann nicht geändert werden)
3. Das Passwort kann nur vom Benutzer selbst geändert werden
4. Klicken Sie auf "Speichern"
BERECHTIGUNGEN
--------------
Verfügbare Berechtigungen für reguläre Benutzer:
- Genehmigung: Kann Vorschläge genehmigen
Administratoren haben automatisch alle Verwaltungsrechte, aber keinen
Zugang zur regulären App.
BENUTZER ENTSPERREN
-------------------
Nach mehreren fehlgeschlagenen Anmeldeversuchen wird ein Konto gesperrt.
1. Öffnen Sie den Benutzer zur Bearbeitung
2. Klicken Sie auf "Entsperren"
BENUTZER LÖSCHEN
----------------
1. Öffnen Sie den Benutzer zur Bearbeitung
2. Klicken Sie auf "Löschen"
3. Bestätigen Sie die Löschung
HINWEIS: Der eigene Account kann nicht gelöscht werden.
================================================================================
10. TIPPS UND TRICKS
================================================================================
SUCHE
-----
- Nutzen Sie das Suchfeld in der Kopfzeile
- Die Suche filtert Aufgaben nach Titel und Beschreibung
- Druecken Sie "Esc" oder das X um die Suche zu loeschen
- Der Suchfilter wird beim Neuladen zurueckgesetzt
FILTER KOMBINIEREN
------------------
Kombinieren Sie mehrere Filter gleichzeitig:
- Benutzer + Prioritaet + Label + Faelligkeit
"Filter zuruecksetzen" setzt alle Filter auf Standard.
UEBERFAELLIGE AUFGABEN
----------------------
- Aufgaben nach dem Enddatum werden mit einem roten Ausrufezeichen markiert
- Die Statistik-Leiste zeigt die Anzahl ueberfaelliger Aufgaben
- Erledigte Aufgaben werden nicht als ueberfaellig markiert
OFFLINE-MODUS
-------------
TaskMate funktioniert auch ohne Internetverbindung:
- Aenderungen werden lokal gespeichert
- Bei Wiederverbindung werden Aenderungen synchronisiert
- Der Status wird in der Kopfzeile angezeigt (Online/Offline)
MARKDOWN IN BESCHREIBUNGEN
--------------------------
Nutzen Sie Markdown-Formatierung in Aufgabenbeschreibungen:
- **fett** oder __fett__
- *kursiv* oder _kursiv_
- # Ueberschrift
- - Aufzaehlung
- [Link](URL)
- `Code`
================================================================================
TASTENKOMBINATIONEN
================================================================================
Esc - Modal schliessen / Suche zuruecksetzen
================================================================================
HAEUFIGE FRAGEN
================================================================================
F: Wie kann ich eine Aufgabe einem anderen Benutzer zuweisen?
A: Oeffnen Sie die Aufgabe und waehlen Sie den Benutzer im Feld "Zustaendig".
F: Kann ich geloeschte Aufgaben wiederherstellen?
A: Nein, geloeschte Aufgaben sind dauerhaft entfernt. Nutzen Sie stattdessen
die Archiv-Funktion.
F: Wie aendere ich die Reihenfolge der Spalten?
A: Ziehen Sie den Spaltentitel an die gewuenschte Position.
F: Werden meine Daten automatisch gespeichert?
A: Ja, alle Aenderungen werden sofort automatisch gespeichert (Auto-Save).
F: Kann ich TaskMate auf dem Smartphone nutzen?
A: Ja, TaskMate ist responsiv und funktioniert auf mobilen Geraeten.
================================================================================
SUPPORT
================================================================================
Bei Fragen oder Problemen wenden Sie sich an Ihren Administrator.
Systemvoraussetzungen:
- Moderner Webbrowser (Chrome, Firefox, Safari, Edge)
- JavaScript muss aktiviert sein
- Internetverbindung (fuer Synchronisation)
================================================================================

1239
CHANGELOG.txt Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden

55
CLAUDE.md Normale Datei
Datei anzeigen

@ -0,0 +1,55 @@
# 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.
## Technologie
- Frontend: Vanilla JavaScript (kein Framework)
- Backend: Node.js mit Express
- Datenbank: SQLite
## Konventionen
- CSS-Variablen in frontend/css/variables.css
- Deutsche Umlaute (ä, ö, ü) in Texten verwenden
## 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}`;
// Falsch: UTC-Konvertierung
const dateStr = date.toISOString().split('T')[0]; // NICHT VERWENDEN!
```
## 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?
## 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.

118
CLAUDE_PROJECT_README.md Normale Datei
Datei anzeigen

@ -0,0 +1,118 @@
# TaskMate
*This README was automatically generated by Claude Project Manager*
## Project Overview
- **Path**: `C:/Users/hendr/Desktop/IntelSight/Projektablage/TaskMate`
- **Files**: 120 files
- **Size**: 10.5 MB
- **Last Modified**: 2025-12-19 00:30
## Technology Stack
### Languages
- JavaScript
### Frameworks & Libraries
- React
## Project Structure
```
docker-compose.yml
Dockerfile
ENTWICKLUNGSSTAND.txt
nul
package-lock.json
SERVER_SETUP_ANLEITUNG.md
ANWENDUNGSBESCHREIBUNG.txt
backend/
│ ├── database.js
│ ├── package-lock.json
│ ├── package.json
│ ├── server.js
│ ├── middleware/
│ │ ├── auth.js
│ │ ├── csrf.js
│ │ ├── upload.js
│ │ └── validation.js
│ ├── routes/
│ │ ├── auth.js
│ │ ├── columns.js
│ │ ├── comments.js
│ │ ├── export.js
│ │ ├── files.js
│ │ ├── health.js
│ │ ├── import.js
│ │ ├── labels.js
│ │ ├── links.js
│ │ └── projects.js
│ └── utils/
│ ├── backup.js
│ └── logger.js
backups/
│ ├── backup_2025-12-18T14-03-57-480Z.db
│ ├── backup_2025-12-18T14-03-57-480Z.db-wal
│ ├── backup_2025-12-18T20-17-10-823Z.db
│ ├── backup_2025-12-18T20-17-10-823Z.db-wal
│ ├── backup_2025-12-18T20-18-39-572Z.db
│ ├── backup_2025-12-18T20-18-39-572Z.db-wal
│ ├── backup_2025-12-18T20-29-09-364Z.db
│ ├── backup_2025-12-18T20-29-09-364Z.db-wal
│ ├── backup_2025-12-18T20-39-22-492Z.db
│ └── backup_2025-12-18T20-39-22-492Z.db-wal
data/
│ ├── taskmate.db
│ ├── taskmate.db-shm
│ └── taskmate.db-wal
frontend/
│ ├── index.html
│ ├── sw.js
│ ├── css/
│ │ ├── base.css
│ │ ├── board.css
│ │ ├── calendar.css
│ │ ├── components.css
│ │ ├── dashboard.css
│ │ ├── modal.css
│ │ ├── responsive.css
│ │ └── variables.css
│ └── js/
│ ├── api.js
│ ├── app.js
│ ├── auth.js
│ ├── board.js
│ ├── calendar.js
│ ├── dashboard.js
│ ├── list.js
│ ├── offline.js
│ ├── shortcuts.js
│ └── store.js
logs/
│ └── app.log
uploads
```
## Key Files
- `Dockerfile`
- `package.json`
## Claude Integration
This project is managed with Claude Project Manager. To work with this project:
1. Open Claude Project Manager
2. Click on this project's tile
3. Claude will open in the project directory
## Notes
*Add your project-specific notes here*
---
## Development Log
- README generated on 2025-12-19 00:30:21

48
Dockerfile Normale Datei
Datei anzeigen

@ -0,0 +1,48 @@
# Node.js LTS Version
FROM node:20-alpine
# Arbeitsverzeichnis
WORKDIR /app
# Build-Abhängigkeiten für better-sqlite3 und Curl für Health-Check installieren
RUN apk add --no-cache curl python3 make g++
# Git für Repository-Operationen installieren (bleibt im Image)
RUN apk add --no-cache git
# Git Safe Directory für gemountete Windows-Verzeichnisse konfigurieren (system-weit für alle User)
RUN git config --system --add safe.directory '*'
# Git-Benutzer konfigurieren (für Commits)
RUN git config --system user.email "taskmate@local" && \
git config --system user.name "TaskMate"
# Package-Dateien kopieren
COPY backend/package*.json ./
# Abhängigkeiten installieren
RUN npm ci --only=production
# Build-Abhängigkeiten entfernen (kleineres Image)
RUN apk del python3 make g++
# Backend-Code kopieren
COPY backend/ ./
# Frontend kopieren
COPY frontend/ ./public/
# Ordner erstellen
RUN mkdir -p /app/data /app/backups /app/logs /app/uploads
# Berechtigungen setzen
RUN chown -R node:node /app
# Als non-root User ausführen
USER node
# Port freigeben
EXPOSE 3000
# Server starten
CMD ["node", "server.js"]

357
GITEA_IMPLEMENTIERUNGSPLAN.txt Normale Datei
Datei anzeigen

@ -0,0 +1,357 @@
================================================================================
TASKMATE - GITEA-INTEGRATION IMPLEMENTIERUNGSPLAN
================================================================================
Erstellt: 28.12.2025
Status: In Bearbeitung
================================================================================
UEBERSICHT
================================================================================
Neuer "Gitea"-Tab neben Board, Liste, Kalender, Genehmigung fuer die
Git-Repository-Verwaltung pro Projekt.
KERNKONZEPT: Jedes TaskMate-Projekt = Ein Git-Repository
Workflow:
1. Projekt in TaskMate erstellen/auswaehlen
2. Gitea-Repository verknuepfen (bestehend oder neu erstellen)
3. Lokales Verzeichnis angeben (wo Claude Code arbeitet)
4. Git-Operationen ausfuehren (Pull, Push, Commit, etc.)
================================================================================
AKTUELLER STAND (VOR IMPLEMENTIERUNG)
================================================================================
BACKEND - Bereits implementiert (aber NICHT aktiviert!):
[x] backend/services/giteaService.js - Gitea API Integration
[x] backend/services/gitService.js - Lokale Git-Operationen
[x] backend/routes/git.js - 11 Git-Endpoints
[x] backend/routes/applications.js - Projekt-Repository-Verknuepfung
[x] Datenbank-Tabelle "applications" vorhanden
KRITISCHER BUG:
[ ] Routes sind in server.js NICHT registriert - API funktioniert nicht!
FRONTEND - Fehlt komplett:
[ ] Kein Gitea-Tab
[ ] Keine UI fuer Repository-Verwaltung
[ ] Keine Git-Operationen im Frontend
================================================================================
IMPLEMENTIERUNGSSCHRITTE
================================================================================
PHASE 1: BACKEND AKTIVIEREN
--------------------------------------------------------------------------------
Schritt 1.1: Routes in server.js registrieren
Datei: backend/server.js
Imports hinzufuegen:
const gitRoutes = require('./routes/git');
const applicationsRoutes = require('./routes/applications');
const giteaRoutes = require('./routes/gitea');
Routes registrieren:
app.use('/api/git', authenticateToken, csrfProtection, gitRoutes);
app.use('/api/applications', authenticateToken, csrfProtection, applicationsRoutes);
app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
Status: [ ] Ausstehend
Schritt 1.2: Neue Gitea-Route erstellen
Datei: backend/routes/gitea.js (NEU)
Endpoints:
- GET /api/gitea/test - Verbindung testen
- GET /api/gitea/repositories - Alle Repos auflisten
- POST /api/gitea/repositories - Neues Repo erstellen
- GET /api/gitea/repositories/:owner/:repo - Repo-Details
- GET /api/gitea/repositories/:owner/:repo/branches - Branches
- GET /api/gitea/repositories/:owner/:repo/commits - Commits
Status: [ ] Ausstehend
--------------------------------------------------------------------------------
PHASE 2: FRONTEND API ERWEITERN
--------------------------------------------------------------------------------
Schritt 2.1: API-Client erweitern
Datei: frontend/js/api.js
Neue Methoden:
// Gitea
testGiteaConnection()
getGiteaRepositories()
createGiteaRepository(data)
getGiteaRepository(owner, repo)
getGiteaBranches(owner, repo)
getGiteaCommits(owner, repo, options)
// Applications
getProjectApplication(projectId)
saveProjectApplication(data)
deleteProjectApplication(projectId)
getUserBasePath()
setUserBasePath(basePath)
// Git Operations
cloneRepository(data)
getGitStatus(projectId)
gitPull(projectId, branch)
gitPush(projectId, branch)
gitCommit(projectId, message, stageAll)
getGitCommits(projectId, limit)
getGitBranches(projectId)
gitCheckout(projectId, branch)
gitFetch(projectId)
validatePath(path)
Status: [ ] Ausstehend
--------------------------------------------------------------------------------
PHASE 3: HTML-STRUKTUR
--------------------------------------------------------------------------------
Schritt 3.1: Navigation-Tab hinzufuegen
Datei: frontend/index.html
Nach proposals-Tab einfuegen:
<button class="view-tab" data-view="gitea">Gitea</button>
Status: [ ] Ausstehend
Schritt 3.2: Gitea-View hinzufuegen
Datei: frontend/index.html
Struktur:
<div id="view-gitea" class="view view-gitea hidden">
<!-- Konfiguration (wenn nicht verknuepft) -->
<div id="gitea-config-section">
- Gitea-Verbindungsstatus
- Repository-Dropdown (bestehende auswaehlen)
- "Neues Repository erstellen" Button
- Lokaler Pfad Eingabe
- Standard-Branch
- Speichern Button
</div>
<!-- Hauptansicht (wenn verknuepft) -->
<div id="gitea-main-section">
- Repository-Name und URL
- Aktueller Branch (Dropdown)
- Status-Badge (Clean/Dirty/Ahead/Behind)
- Git-Operationen (Fetch, Pull, Push, Commit)
- Aenderungen-Liste
- Commit-Historie
</div>
<!-- Leer-Zustand -->
<div id="gitea-no-project">
Kein Projekt ausgewaehlt
</div>
</div>
Status: [ ] Ausstehend
Schritt 3.3: Modals hinzufuegen
Datei: frontend/index.html
- #git-commit-modal - Commit-Nachricht eingeben
- #create-repo-modal - Neues Repository erstellen
Status: [ ] Ausstehend
--------------------------------------------------------------------------------
PHASE 4: CSS-STYLES
--------------------------------------------------------------------------------
Schritt 4.1: Gitea-Styles erstellen
Datei: frontend/css/gitea.css (NEU)
Styles fuer:
- .view-gitea - Hauptcontainer
- .gitea-section - Sektionen
- .gitea-connection-status - Verbindungsanzeige
- .gitea-repo-header - Repository-Header
- .gitea-status-panel - Status-Grid
- .status-badge - Clean/Dirty/Ahead Badges
- .gitea-operations-panel - Button-Grid
- .changes-list - Geaenderte Dateien
- .commits-list - Commit-Historie
- .gitea-empty-state - Leer-Zustand
Status: [ ] Ausstehend
Schritt 4.2: CSS in index.html einbinden
<link rel="stylesheet" href="css/gitea.css">
Status: [ ] Ausstehend
--------------------------------------------------------------------------------
PHASE 5: GITEA MANAGER
--------------------------------------------------------------------------------
Schritt 5.1: Gitea Manager erstellen
Datei: frontend/js/gitea.js (NEU)
Klasse GiteaManager:
Properties:
- application (Projekt-Repository-Verknuepfung)
- gitStatus (Aktueller Git-Status)
- branches (Verfuegbare Branches)
- commits (Commit-Historie)
- giteaRepos (Gitea-Repositories)
- giteaConnected (Verbindungsstatus)
Methoden:
- init() - Initialisierung
- bindEvents() - Event-Listener
- subscribeToStore() - Store-Subscriptions
- loadApplication() - Anwendung laden
- loadGitData() - Git-Daten laden
- loadGiteaRepos() - Gitea-Repos laden
- renderConfigurationView() - Konfig-Ansicht
- renderConfiguredView() - Hauptansicht
- renderStatus() - Status rendern
- renderBranches() - Branches rendern
- renderCommits() - Commits rendern
- renderChanges() - Aenderungen rendern
- handleConfigSave() - Konfig speichern
- handleRepoSelect() - Repo auswaehlen
- handleBranchChange() - Branch wechseln
- handleFetch/Pull/Push/Commit() - Git-Ops
- validateLocalPath() - Pfad validieren
- show()/hide() - View-Kontrolle
- startAutoRefresh() - Auto-Refresh
Status: [ ] Ausstehend
--------------------------------------------------------------------------------
PHASE 6: APP-INTEGRATION
--------------------------------------------------------------------------------
Schritt 6.1: Manager in app.js integrieren
Datei: frontend/js/app.js
Import:
import giteaManager from './gitea.js';
In initializeApp():
await giteaManager.init();
In switchView():
if (view === 'gitea') {
giteaManager.show();
} else {
giteaManager.hide();
}
Status: [ ] Ausstehend
--------------------------------------------------------------------------------
PHASE 7: FINALISIERUNG
--------------------------------------------------------------------------------
Schritt 7.1: Service Worker aktualisieren
Datei: frontend/sw.js
- Cache-Version erhoehen
- gitea.js und gitea.css zum Cache hinzufuegen
Status: [ ] Ausstehend
Schritt 7.2: CHANGELOG.txt aktualisieren
Dokumentation der neuen Gitea-Integration
Status: [ ] Ausstehend
Schritt 7.3: Docker-Container neu bauen und testen
docker compose down
docker compose up --build -d
Status: [ ] Ausstehend
================================================================================
UI-MOCKUP: GITEA-TAB
================================================================================
+------------------------------------------------------------------+
| [Board] [Liste] [Kalender] [Genehmigung] [Gitea] |
+------------------------------------------------------------------+
| |
| +------------------------------------------------------------+ |
| | IntelSight/AccountForger-neuerUpload [Edit][X] | |
| | https://gitea-undso.aegis-sight.de/... | |
| +------------------------------------------------------------+ |
| |
| +------------------------------------------------------------+ |
| | Branch: [main v] Status: Clean Aenderungen: 0 | |
| | Letzte Sync: vor 5 Minuten | |
| +------------------------------------------------------------+ |
| |
| +------------------------------------------------------------+ |
| | Git-Operationen | |
| | [Fetch] [Pull] [Push] [Commit] | |
| +------------------------------------------------------------+ |
| |
| +------------------------------------------------------------+ |
| | Letzte Commits | |
| | +--------------------------------------------------------+ | |
| | | [a1b2c3d] Fix: Login-Bug behoben | | |
| | | HG - vor 2 Stunden | | |
| | +--------------------------------------------------------+ | |
| | | [e4f5g6h] Feature: Neue Filteroptionen | | |
| | | MH - vor 1 Tag | | |
| | +--------------------------------------------------------+ | |
| +------------------------------------------------------------+ |
| |
+------------------------------------------------------------------+
================================================================================
KRITISCHE DATEIEN
================================================================================
Datei | Aktion
-------------------------------|------------------------------------------
backend/server.js | Routes registrieren (KRITISCH!)
backend/routes/gitea.js | NEU erstellen
frontend/js/api.js | Erweitern
frontend/js/gitea.js | NEU erstellen
frontend/css/gitea.css | NEU erstellen
frontend/index.html | View + Tab + Modals
frontend/js/app.js | Import + Integration
frontend/sw.js | Cache-Version erhoehen
CHANGELOG.txt | Dokumentieren
================================================================================
IMPLEMENTIERUNGS-REIHENFOLGE (CHECKLISTE)
================================================================================
[ ] 1. Backend: Routes in server.js registrieren
[ ] 2. Backend: gitea.js Route erstellen
[ ] 3. Frontend: api.js erweitern
[ ] 4. Frontend: index.html (View + Tab + Modals)
[ ] 5. Frontend: gitea.css erstellen
[ ] 6. Frontend: gitea.js Manager erstellen
[ ] 7. Frontend: app.js Integration
[ ] 8. Service Worker aktualisieren
[ ] 9. CHANGELOG.txt aktualisieren
[ ] 10. Docker-Container neu bauen und testen
================================================================================
KONFIGURATION (BEREITS VORHANDEN)
================================================================================
.env Datei:
GITEA_URL=https://gitea-undso.aegis-sight.de
GITEA_TOKEN=8d76ec66b3e1f02e9b1e4848d8b15e0cdffe48df
Beispiel-Repository:
Organisation: IntelSight
Name: AccountForger-neuerUpload
================================================================================

398
SERVER_SETUP_ANLEITUNG.md Normale Datei
Datei anzeigen

@ -0,0 +1,398 @@
# Server-Setup Anleitung für TaskMate
Diese Anleitung erklärt Schritt für Schritt, wie Sie TaskMate auf Ihrem Linux-Server installieren.
---
## Voraussetzungen
- Ein Linux-Server (Ubuntu 20.04/22.04 oder Debian empfohlen)
- SSH-Zugang zu Ihrem Server
- Root-Rechte oder sudo-Berechtigungen
---
## Schritt 1: Mit dem Server verbinden
Öffnen Sie ein Terminal (Windows: PowerShell oder CMD) und verbinden Sie sich per SSH:
```bash
ssh benutzername@ihre-server-ip
```
Ersetzen Sie `benutzername` mit Ihrem Benutzernamen und `ihre-server-ip` mit der IP-Adresse Ihres Servers.
---
## Schritt 2: System aktualisieren
```bash
sudo apt update
sudo apt upgrade -y
```
---
## Schritt 3: Docker installieren
### 3.1 Erforderliche Pakete installieren
```bash
sudo apt install -y ca-certificates curl gnupg lsb-release
```
### 3.2 Docker GPG-Schlüssel hinzufügen
```bash
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
```
### 3.3 Docker-Repository hinzufügen
Für Ubuntu:
```bash
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
```
Für Debian:
```bash
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
```
### 3.4 Docker installieren
```bash
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
```
### 3.5 Docker für Ihren Benutzer aktivieren
```bash
sudo usermod -aG docker $USER
```
**Wichtig:** Melden Sie sich ab und wieder an, damit diese Änderung wirksam wird:
```bash
exit
```
Dann erneut verbinden mit SSH.
### 3.6 Docker testen
```bash
docker --version
docker compose version
```
---
## Schritt 4: Projektdateien auf den Server übertragen
### Option A: Mit SCP (vom lokalen Computer)
Öffnen Sie ein **neues** Terminal auf Ihrem lokalen Computer:
```bash
scp -r C:\Users\hendr\Desktop\IntelSight\Projektablage\TaskMate benutzername@ihre-server-ip:~/
```
### Option B: Mit Git (falls vorhanden)
Falls Sie die Dateien in einem Git-Repository haben:
```bash
git clone ihr-repository-url ~/TaskMate
```
### Option C: Mit FileZilla (grafisch)
1. Laden Sie FileZilla herunter: https://filezilla-project.org/
2. Verbinden Sie sich:
- Host: `sftp://ihre-server-ip`
- Benutzername: Ihr Benutzername
- Passwort: Ihr Passwort
- Port: 22
3. Ziehen Sie den Ordner `TaskMate` in Ihr Home-Verzeichnis
---
## Schritt 5: Umgebungsvariablen konfigurieren
Wechseln Sie in das Projektverzeichnis:
```bash
cd ~/TaskMate
```
Erstellen Sie die Konfigurationsdatei:
```bash
cp .env.example .env
```
Öffnen Sie die Datei zum Bearbeiten:
```bash
nano .env
```
Ändern Sie folgende Werte:
```env
# WICHTIG: Ändern Sie diese Werte!
JWT_SECRET=IhrGeheimesPasswort123!
SESSION_SECRET=EinAnderesGeheimesPasswort456!
# Optional: Port ändern (Standard: 3000)
PORT=3000
```
**Tipp für sichere Passwörter:**
```bash
openssl rand -base64 32
```
Kopieren Sie diese generierten Passwörter in die .env-Datei.
Speichern mit: `Ctrl + O`, `Enter`, `Ctrl + X`
---
## Schritt 6: Anwendung starten
### Erster Start (dauert einige Minuten)
```bash
docker compose up -d
```
Die `-d` Option startet die Container im Hintergrund.
### Status prüfen
```bash
docker compose ps
```
Sie sollten sehen, dass der Container "running" ist.
### Logs anzeigen
```bash
docker compose logs -f
```
Mit `Ctrl + C` können Sie die Logs beenden.
---
## Schritt 7: Firewall konfigurieren (falls aktiv)
```bash
sudo ufw allow 3000/tcp
sudo ufw status
```
---
## Schritt 8: Anwendung aufrufen
Öffnen Sie in Ihrem Browser:
```
http://ihre-server-ip:3000
```
### Erste Anmeldung
- Benutzername: `admin`
- Passwort: `admin123`
**WICHTIG:** Ändern Sie das Passwort sofort nach der ersten Anmeldung!
---
## Nützliche Befehle
### Anwendung stoppen
```bash
docker compose stop
```
### Anwendung neu starten
```bash
docker compose restart
```
### Anwendung komplett beenden und entfernen
```bash
docker compose down
```
### Logs anzeigen
```bash
docker compose logs -f
```
### Container-Status prüfen
```bash
docker compose ps
```
### Anwendung aktualisieren
Falls Sie Änderungen am Code gemacht haben:
```bash
docker compose down
docker compose build --no-cache
docker compose up -d
```
---
## Automatischer Start beim Serverstart
Um die Anwendung automatisch zu starten, wenn der Server neu startet:
```bash
sudo systemctl enable docker
```
Docker Compose startet Container mit `restart: unless-stopped` automatisch.
---
## Backups
Die Datenbank wird automatisch täglich gesichert. Die Backups finden Sie unter:
```
~/TaskMate/backups/
```
### Manuelles Backup erstellen
```bash
cp ~/TaskMate/data/database.sqlite ~/TaskMate/backups/backup_$(date +%Y%m%d).sqlite
```
### Backup wiederherstellen
```bash
docker compose stop
cp ~/TaskMate/backups/backup_DATUM.sqlite ~/TaskMate/data/database.sqlite
docker compose start
```
---
## HTTPS mit SSL-Zertifikat (Optional, aber empfohlen)
Für eine sichere Verbindung empfehlen wir die Einrichtung von HTTPS.
### Option A: Mit Nginx als Reverse Proxy
1. Nginx installieren:
```bash
sudo apt install -y nginx
```
2. Certbot für Let's Encrypt installieren:
```bash
sudo apt install -y certbot python3-certbot-nginx
```
3. Nginx-Konfiguration erstellen:
```bash
sudo nano /etc/nginx/sites-available/taskmate
```
Inhalt:
```nginx
server {
listen 80;
server_name ihre-domain.de;
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_cache_bypass $http_upgrade;
}
}
```
4. Konfiguration aktivieren:
```bash
sudo ln -s /etc/nginx/sites-available/taskmate /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
5. SSL-Zertifikat erstellen:
```bash
sudo certbot --nginx -d ihre-domain.de
```
Folgen Sie den Anweisungen auf dem Bildschirm.
---
## Fehlerbehebung
### Container startet nicht
1. Prüfen Sie die Logs:
```bash
docker compose logs
```
2. Prüfen Sie, ob Port 3000 bereits verwendet wird:
```bash
sudo lsof -i :3000
```
3. Falls ja, ändern Sie den Port in der `.env`-Datei.
### Verbindung nicht möglich
1. Prüfen Sie die Firewall:
```bash
sudo ufw status
```
2. Prüfen Sie, ob Docker läuft:
```bash
sudo systemctl status docker
```
### Datenbank-Fehler
1. Stoppen Sie die Anwendung:
```bash
docker compose stop
```
2. Prüfen Sie die Datenbankdatei:
```bash
ls -la ~/TaskMate/data/
```
3. Falls beschädigt, stellen Sie ein Backup wieder her (siehe oben).
---
## Unterstützung
Bei Problemen prüfen Sie:
1. Die Logs (`docker compose logs -f`)
2. Ob alle Container laufen (`docker compose ps`)
3. Die Firewall-Einstellungen
4. Die .env-Konfiguration
---
## Sicherheitshinweise
1. **Ändern Sie die Standard-Passwörter** sofort nach der Installation
2. **Halten Sie Docker aktuell**: `sudo apt update && sudo apt upgrade`
3. **Regelmäßige Backups** - Die automatischen Backups prüfen
4. **HTTPS verwenden** für Produktionsumgebungen
5. **Firewall konfigurieren** - Nur notwendige Ports öffnen

536
backend/database.js Normale Datei
Datei anzeigen

@ -0,0 +1,536 @@
/**
* TASKMATE - Datenbank
* ====================
* SQLite-Datenbank mit better-sqlite3
*/
const Database = require('better-sqlite3');
const path = require('path');
const bcrypt = require('bcryptjs');
const logger = require('./utils/logger');
// Datenbank-Pfad
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'taskmate.db');
let db = null;
/**
* Datenbank initialisieren
*/
async function initialize() {
try {
// Datenbank öffnen/erstellen
db = new Database(DB_PATH);
// WAL-Modus für bessere Performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// Tabellen erstellen
createTables();
// Standard-Benutzer erstellen (falls nicht vorhanden)
await createDefaultUsers();
logger.info('Datenbank initialisiert');
return db;
} catch (error) {
logger.error('Datenbank-Fehler:', error);
throw error;
}
}
/**
* Tabellen erstellen
*/
function createTables() {
// Benutzer
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#00D4FF',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_login DATETIME,
failed_attempts INTEGER DEFAULT 0,
locked_until DATETIME
)
`);
// Login-Audit
db.exec(`
CREATE TABLE IF NOT EXISTS login_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
ip_address TEXT,
success INTEGER NOT NULL,
user_agent TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Projekte
db.exec(`
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
archived INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Spalten
db.exec(`
CREATE TABLE IF NOT EXISTS columns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
position INTEGER NOT NULL,
color TEXT,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
)
`);
// Labels
db.exec(`
CREATE TABLE IF NOT EXISTS labels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
)
`);
// Aufgaben
db.exec(`
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
column_id INTEGER NOT NULL,
title TEXT NOT NULL,
description TEXT,
priority TEXT DEFAULT 'medium',
start_date DATE,
due_date DATE,
assigned_to INTEGER,
time_estimate_min INTEGER,
depends_on INTEGER,
position INTEGER NOT NULL,
archived INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE,
FOREIGN KEY (assigned_to) REFERENCES users(id),
FOREIGN KEY (depends_on) REFERENCES tasks(id) ON DELETE SET NULL,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Migration: Add start_date column if it doesn't exist
const columns = db.prepare("PRAGMA table_info(tasks)").all();
const hasStartDate = columns.some(col => col.name === 'start_date');
if (!hasStartDate) {
db.exec('ALTER TABLE tasks ADD COLUMN start_date DATE');
logger.info('Migration: start_date Spalte zu tasks hinzugefuegt');
}
// Migration: Add role and permissions columns to users
const userColumns = db.prepare("PRAGMA table_info(users)").all();
const hasRole = userColumns.some(col => col.name === 'role');
if (!hasRole) {
db.exec("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'");
logger.info('Migration: role Spalte zu users hinzugefuegt');
}
const hasPermissions = userColumns.some(col => col.name === 'permissions');
if (!hasPermissions) {
db.exec("ALTER TABLE users ADD COLUMN permissions TEXT DEFAULT '[]'");
logger.info('Migration: permissions Spalte zu users hinzugefuegt');
}
// Migration: Add email column to users
const hasEmail = userColumns.some(col => col.name === 'email');
if (!hasEmail) {
db.exec("ALTER TABLE users ADD COLUMN email TEXT");
logger.info('Migration: email Spalte zu users hinzugefuegt');
}
// Migration: Add repositories_base_path column to users
const hasRepoBasePath = userColumns.some(col => col.name === 'repositories_base_path');
if (!hasRepoBasePath) {
db.exec("ALTER TABLE users ADD COLUMN repositories_base_path TEXT");
logger.info('Migration: repositories_base_path Spalte zu users hinzugefuegt');
}
// Proposals (Vorschlaege)
db.exec(`
CREATE TABLE IF NOT EXISTS proposals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT,
created_by INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
approved INTEGER DEFAULT 0,
approved_by INTEGER,
approved_at DATETIME,
FOREIGN KEY (created_by) REFERENCES users(id),
FOREIGN KEY (approved_by) REFERENCES users(id)
)
`);
// Proposal Votes
db.exec(`
CREATE TABLE IF NOT EXISTS proposal_votes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proposal_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (proposal_id) REFERENCES proposals(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Unique constraint for proposal votes (one vote per user per proposal)
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_proposal_votes_unique
ON proposal_votes(proposal_id, user_id)
`);
// Index for proposal votes
db.exec(`
CREATE INDEX IF NOT EXISTS idx_proposal_votes_proposal
ON proposal_votes(proposal_id)
`);
// Migration: Add archived, task_id, and project_id columns to proposals
const proposalColumns = db.prepare("PRAGMA table_info(proposals)").all();
const hasProposalArchived = proposalColumns.some(col => col.name === 'archived');
if (!hasProposalArchived) {
db.exec('ALTER TABLE proposals ADD COLUMN archived INTEGER DEFAULT 0');
logger.info('Migration: archived Spalte zu proposals hinzugefuegt');
}
const hasProposalTaskId = proposalColumns.some(col => col.name === 'task_id');
if (!hasProposalTaskId) {
db.exec('ALTER TABLE proposals ADD COLUMN task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL');
logger.info('Migration: task_id Spalte zu proposals hinzugefuegt');
}
const hasProposalProjectId = proposalColumns.some(col => col.name === 'project_id');
if (!hasProposalProjectId) {
db.exec('ALTER TABLE proposals ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE');
logger.info('Migration: project_id Spalte zu proposals hinzugefuegt');
}
// Migration: Add filter_category column to columns
const columnColumns = db.prepare("PRAGMA table_info(columns)").all();
const hasFilterCategory = columnColumns.some(col => col.name === 'filter_category');
if (!hasFilterCategory) {
db.exec("ALTER TABLE columns ADD COLUMN filter_category TEXT DEFAULT 'in_progress'");
logger.info('Migration: filter_category Spalte zu columns hinzugefuegt');
// Set default values for existing columns based on position
const projects = db.prepare('SELECT id FROM projects').all();
for (const project of projects) {
const cols = db.prepare('SELECT id, position FROM columns WHERE project_id = ? ORDER BY position').all(project.id);
if (cols.length > 0) {
// First column = open
db.prepare("UPDATE columns SET filter_category = 'open' WHERE id = ?").run(cols[0].id);
// Last column = completed
if (cols.length > 1) {
db.prepare("UPDATE columns SET filter_category = 'completed' WHERE id = ?").run(cols[cols.length - 1].id);
}
}
}
logger.info('Migration: Standard-Filterkategorien fuer bestehende Spalten gesetzt');
}
// Task-Labels (Verknüpfung)
db.exec(`
CREATE TABLE IF NOT EXISTS task_labels (
task_id INTEGER NOT NULL,
label_id INTEGER NOT NULL,
PRIMARY KEY (task_id, label_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
)
`);
// Task-Assignees (Mehrfachzuweisung von Mitarbeitern)
db.exec(`
CREATE TABLE IF NOT EXISTS task_assignees (
task_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
PRIMARY KEY (task_id, user_id),
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
// Unteraufgaben
db.exec(`
CREATE TABLE IF NOT EXISTS subtasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
title TEXT NOT NULL,
completed INTEGER DEFAULT 0,
position INTEGER NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
)
`);
// Kommentare
db.exec(`
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
content TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Anhänge
db.exec(`
CREATE TABLE IF NOT EXISTS attachments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_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 (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (uploaded_by) REFERENCES users(id)
)
`);
// Links
db.exec(`
CREATE TABLE IF NOT EXISTS links (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
title TEXT,
url TEXT NOT NULL,
created_by INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Aufgaben-Vorlagen
db.exec(`
CREATE TABLE IF NOT EXISTS task_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
title_template TEXT,
description TEXT,
priority TEXT,
labels TEXT,
subtasks TEXT,
time_estimate_min INTEGER,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
)
`);
// Historie
db.exec(`
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
action TEXT NOT NULL,
field_changed TEXT,
old_value TEXT,
new_value TEXT,
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
// Einstellungen
db.exec(`
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT
)
`);
// Anwendungen (Git-Repositories pro Projekt)
db.exec(`
CREATE TABLE IF NOT EXISTS applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL UNIQUE,
local_path TEXT NOT NULL,
gitea_repo_url TEXT,
gitea_repo_owner TEXT,
gitea_repo_name TEXT,
default_branch TEXT DEFAULT 'main',
last_sync DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
created_by INTEGER,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
)
`);
// Benachrichtigungen (Inbox)
db.exec(`
CREATE TABLE IF NOT EXISTS notifications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
type TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT,
task_id INTEGER,
project_id INTEGER,
proposal_id INTEGER,
actor_id INTEGER,
is_read INTEGER DEFAULT 0,
is_persistent INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (proposal_id) REFERENCES proposals(id) ON DELETE CASCADE,
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
)
`);
// Indizes für Performance
db.exec(`
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_column ON tasks(column_id);
CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
CREATE INDEX IF NOT EXISTS idx_subtasks_task ON subtasks(task_id);
CREATE INDEX IF NOT EXISTS idx_comments_task ON comments(task_id);
CREATE INDEX IF NOT EXISTS idx_history_task ON history(task_id);
CREATE INDEX IF NOT EXISTS idx_attachments_task ON attachments(task_id);
CREATE INDEX IF NOT EXISTS idx_links_task ON links(task_id);
CREATE INDEX IF NOT EXISTS idx_task_labels_task ON task_labels(task_id);
CREATE INDEX IF NOT EXISTS idx_task_labels_label ON task_labels(label_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
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);
`);
logger.info('Datenbank-Tabellen erstellt');
}
/**
* Standard-Benutzer erstellen
*/
async function createDefaultUsers() {
const existingUsers = db.prepare('SELECT COUNT(*) as count FROM users').get();
if (existingUsers.count === 0) {
// Benutzer aus Umgebungsvariablen
const user1 = {
username: process.env.USER1_USERNAME || 'user1',
password: process.env.USER1_PASSWORD || 'changeme123',
displayName: process.env.USER1_DISPLAYNAME || 'Benutzer 1',
color: process.env.USER1_COLOR || '#00D4FF'
};
const user2 = {
username: process.env.USER2_USERNAME || 'user2',
password: process.env.USER2_PASSWORD || 'changeme456',
displayName: process.env.USER2_DISPLAYNAME || 'Benutzer 2',
color: process.env.USER2_COLOR || '#FF9500'
};
const insertUser = db.prepare(`
INSERT INTO users (username, password_hash, display_name, color, role, permissions)
VALUES (?, ?, ?, ?, ?, ?)
`);
// Admin-Benutzer
const adminUser = {
username: 'admin',
password: '!1Data123',
displayName: 'Administrator',
color: '#8B5CF6'
};
// Passwoerter hashen und Benutzer erstellen
const hash1 = await bcrypt.hash(user1.password, 12);
const hash2 = await bcrypt.hash(user2.password, 12);
const hashAdmin = await bcrypt.hash(adminUser.password, 12);
insertUser.run(user1.username, hash1, user1.displayName, user1.color, 'user', '[]');
insertUser.run(user2.username, hash2, user2.displayName, user2.color, 'user', '[]');
insertUser.run(adminUser.username, hashAdmin, adminUser.displayName, adminUser.color, 'admin', '[]');
logger.info('Standard-Benutzer und Admin erstellt');
// Standard-Projekt erstellen
const projectResult = db.prepare(`
INSERT INTO projects (name, description, created_by)
VALUES (?, ?, ?)
`).run('Mein erstes Projekt', 'Willkommen bei TaskMate!', 1);
const projectId = projectResult.lastInsertRowid;
// Standard-Spalten erstellen
const insertColumn = db.prepare(`
INSERT INTO columns (project_id, name, position, color)
VALUES (?, ?, ?, ?)
`);
insertColumn.run(projectId, 'Offen', 0, null);
insertColumn.run(projectId, 'In Arbeit', 1, null);
insertColumn.run(projectId, 'Erledigt', 2, null);
// Standard-Labels erstellen
const insertLabel = db.prepare(`
INSERT INTO labels (project_id, name, color)
VALUES (?, ?, ?)
`);
insertLabel.run(projectId, 'Bug', '#DC2626');
insertLabel.run(projectId, 'Feature', '#059669');
insertLabel.run(projectId, 'Dokumentation', '#3182CE');
logger.info('Standard-Projekt mit Spalten und Labels erstellt');
}
}
/**
* Datenbank-Instanz abrufen
*/
function getDb() {
if (!db) {
throw new Error('Datenbank nicht initialisiert');
}
return db;
}
/**
* Datenbank schließen
*/
function close() {
if (db) {
db.close();
logger.info('Datenbank geschlossen');
}
}
module.exports = {
initialize,
getDb,
close
};

189
backend/middleware/auth.js Normale Datei
Datei anzeigen

@ -0,0 +1,189 @@
/**
* TASKMATE - Auth Middleware
* ==========================
* JWT-basierte Authentifizierung
*/
const jwt = require('jsonwebtoken');
const logger = require('../utils/logger');
const JWT_SECRET = process.env.JWT_SECRET || 'UNSICHER_BITTE_AENDERN';
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
/**
* JWT-Token generieren
*/
function generateToken(user) {
// Permissions parsen falls als String gespeichert
let permissions = user.permissions || [];
if (typeof permissions === 'string') {
try {
permissions = JSON.parse(permissions);
} catch (e) {
permissions = [];
}
}
return jwt.sign(
{
id: user.id,
username: user.username,
displayName: user.display_name,
color: user.color,
role: user.role || 'user',
permissions: permissions
},
JWT_SECRET,
{ expiresIn: `${SESSION_TIMEOUT}m` }
);
}
/**
* JWT-Token verifizieren
*/
function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
/**
* Express Middleware: Token aus Header, Cookie oder Query-Parameter prüfen
*/
function authenticateToken(req, res, next) {
// Token aus Authorization Header, Cookie oder Query-Parameter (für img src etc.)
let token = null;
const authHeader = req.headers['authorization'];
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7);
} else if (req.cookies && req.cookies.token) {
token = req.cookies.token;
} else if (req.query && req.query.token) {
// Token aus Query-Parameter (für Ressourcen die in img/video tags geladen werden)
token = req.query.token;
}
if (!token) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
const user = verifyToken(token);
if (!user) {
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
}
// User-Info an Request anhängen
req.user = user;
// Token-Refresh: Wenn Token bald ablaeuft, neuen ausstellen
const tokenExp = user.exp * 1000; // exp ist in Sekunden
const now = Date.now();
const refreshThreshold = 5 * 60 * 1000; // 5 Minuten vor Ablauf
if (tokenExp - now < refreshThreshold) {
const newToken = generateToken({
id: user.id,
username: user.username,
display_name: user.displayName,
color: user.color,
role: user.role,
permissions: user.permissions
});
res.setHeader('X-New-Token', newToken);
}
next();
}
/**
* Middleware: Nur Admins erlauben
*/
function requireAdmin(req, res, next) {
if (!req.user) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
if (req.user.role !== 'admin') {
return res.status(403).json({ error: 'Keine Admin-Berechtigung' });
}
next();
}
/**
* Middleware: Nur regulaere User erlauben (blockiert Admins)
*/
function requireRegularUser(req, res, next) {
if (!req.user) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
if (req.user.role === 'admin') {
return res.status(403).json({ error: 'Admin hat keinen Zugang zur regulaeren App' });
}
next();
}
/**
* Middleware-Factory: Bestimmte Berechtigung pruefen
*/
function checkPermission(permission) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Nicht authentifiziert' });
}
const permissions = req.user.permissions || [];
if (!permissions.includes(permission)) {
return res.status(403).json({ error: `Berechtigung "${permission}" fehlt` });
}
next();
};
}
/**
* Socket.io Middleware: Token prüfen
*/
function authenticateSocket(socket, next) {
const token = socket.handshake.auth.token ||
socket.handshake.headers.authorization?.replace('Bearer ', '');
if (!token) {
return next(new Error('Nicht authentifiziert'));
}
const user = verifyToken(token);
if (!user) {
return next(new Error('Token ungültig oder abgelaufen'));
}
// User-Info an Socket anhängen
socket.user = user;
next();
}
/**
* CSRF-Token generieren (für Forms)
*/
function generateCsrfToken() {
const { randomBytes } = require('crypto');
return randomBytes(32).toString('hex');
}
module.exports = {
generateToken,
verifyToken,
authenticateToken,
authenticateSocket,
generateCsrfToken,
requireAdmin,
requireRegularUser,
checkPermission,
JWT_SECRET,
SESSION_TIMEOUT
};

125
backend/middleware/csrf.js Normale Datei
Datei anzeigen

@ -0,0 +1,125 @@
/**
* TASKMATE - CSRF Schutz
* ======================
* Cross-Site Request Forgery Schutz
*
* Vereinfachtes System: Token wird beim Login generiert und bleibt
* für die gesamte Sitzung gültig (24 Stunden).
*/
const crypto = require('crypto');
const logger = require('../utils/logger');
// CSRF-Tokens speichern (in-memory, pro User)
const csrfTokens = new Map();
// Token-Gültigkeit: 24 Stunden
const TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
/**
* CSRF-Token generieren
*/
function generateToken(userId) {
const token = crypto.randomBytes(32).toString('hex');
const expires = Date.now() + TOKEN_VALIDITY;
csrfTokens.set(userId, { token, expires });
cleanupExpiredTokens();
return token;
}
/**
* CSRF-Token validieren
*/
function validateToken(userId, token) {
const stored = csrfTokens.get(userId);
if (!stored) {
return false;
}
if (Date.now() > stored.expires) {
csrfTokens.delete(userId);
return false;
}
return stored.token === token;
}
/**
* Abgelaufene Tokens aufräumen
*/
function cleanupExpiredTokens() {
const now = Date.now();
for (const [userId, data] of csrfTokens.entries()) {
if (now > data.expires) {
csrfTokens.delete(userId);
}
}
}
// Regelmäßig aufräumen (alle 30 Minuten)
setInterval(cleanupExpiredTokens, 30 * 60 * 1000);
/**
* Express Middleware
*/
function csrfProtection(req, res, next) {
// GET-Anfragen sind sicher (lesen nur)
if (req.method === 'GET') {
return next();
}
// User muss authentifiziert sein
if (!req.user) {
return next();
}
const userId = req.user.id;
// Token aus Header oder Body
const token = req.headers['x-csrf-token'] || req.body?._csrf;
// Wenn kein Token vom Client gesendet oder kein Token auf Server gespeichert
if (!token || !csrfTokens.has(userId)) {
const newToken = generateToken(userId);
logger.info(`CSRF: Token missing or not stored for user ${userId}, generated new token`);
return res.status(403).json({
error: 'CSRF-Token fehlt oder abgelaufen',
csrfToken: newToken,
code: 'CSRF_ERROR'
});
}
// Token validieren
if (!validateToken(userId, token)) {
const newToken = generateToken(userId);
logger.info(`CSRF: Token mismatch for user ${userId}`);
return res.status(403).json({
error: 'Ungültiges CSRF-Token',
csrfToken: newToken,
code: 'CSRF_ERROR'
});
}
// Token ist gültig - KEIN neuer Token generiert (bleibt für die Sitzung gleich)
next();
}
/**
* Aktuellen Token für User abrufen (oder neuen generieren)
*/
function getTokenForUser(userId) {
const stored = csrfTokens.get(userId);
if (stored && Date.now() < stored.expires) {
return stored.token;
}
return generateToken(userId);
}
module.exports = csrfProtection;
module.exports.generateToken = generateToken;
module.exports.getTokenForUser = getTokenForUser;

198
backend/middleware/upload.js Normale Datei
Datei anzeigen

@ -0,0 +1,198 @@
/**
* TASKMATE - File Upload
* ======================
* Multer-Konfiguration für Datei-Uploads
*/
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const logger = require('../utils/logger');
// Upload-Verzeichnis
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
// Verzeichnis erstellen falls nicht vorhanden
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
// 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'
];
/**
* Lädt Upload-Einstellungen aus der Datenbank
*/
function loadUploadSettings() {
try {
// Lazy-Load um zirkuläre Abhängigkeiten zu vermeiden
const { getUploadSettings } = require('../routes/admin');
const settings = getUploadSettings();
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;
}
}
} catch (error) {
// Bei Fehler Standard-Werte beibehalten
logger.warn('Upload-Einstellungen konnten nicht geladen werden, verwende Standards');
}
}
/**
* Aktuelle Einstellungen abrufen (für dynamische Prüfung)
*/
function getCurrentSettings() {
loadUploadSettings();
return { maxFileSize: MAX_FILE_SIZE, allowedMimeTypes: ALLOWED_MIME_TYPES };
}
/**
* Storage-Konfiguration
*/
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Task-ID aus URL oder Body
const taskId = req.params.taskId || req.body.taskId;
if (taskId) {
// Unterordner pro Task
const taskDir = path.join(UPLOAD_DIR, `task_${taskId}`);
if (!fs.existsSync(taskDir)) {
fs.mkdirSync(taskDir, { recursive: true });
}
cb(null, taskDir);
} else {
cb(null, UPLOAD_DIR);
}
},
filename: (req, file, cb) => {
// Eindeutiger Dateiname mit Original-Extension
const ext = path.extname(file.originalname).toLowerCase();
const uniqueName = `${uuidv4()}${ext}`;
cb(null, uniqueName);
}
});
/**
* Datei-Filter
*/
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);
}
};
/**
* Dynamische Multer-Instanz erstellen
*/
function createUpload() {
const settings = getCurrentSettings();
return multer({
storage,
fileFilter,
limits: {
fileSize: settings.maxFileSize,
files: 10 // Maximal 10 Dateien pro Request
}
});
}
// Standard-Instanz für Rückwärtskompatibilität
const upload = createUpload();
/**
* Datei löschen
*/
function deleteFile(filePath) {
try {
const fullPath = path.join(UPLOAD_DIR, filePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
logger.info(`Datei gelöscht: ${filePath}`);
return true;
}
return false;
} catch (error) {
logger.error(`Fehler beim Löschen: ${filePath}`, { error: error.message });
return false;
}
}
/**
* Dateigröße formatieren
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Ist Bild?
*/
function isImage(mimeType) {
return mimeType && mimeType.startsWith('image/');
}
/**
* Datei-Icon basierend auf MIME-Type
*/
function getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType === 'application/pdf') return 'pdf';
if (mimeType.includes('word')) return 'word';
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return 'excel';
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return 'powerpoint';
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('7z')) return 'archive';
if (mimeType.startsWith('text/')) return 'text';
return 'file';
}
module.exports = {
upload,
createUpload,
deleteFile,
formatFileSize,
isImage,
getFileIcon,
getCurrentSettings,
UPLOAD_DIR,
MAX_FILE_SIZE,
ALLOWED_MIME_TYPES
};

Datei anzeigen

@ -0,0 +1,249 @@
/**
* TASKMATE - Input Validierung
* ============================
* Schutz vor SQL-Injection und XSS
*/
const sanitizeHtml = require('sanitize-html');
/**
* HTML-Tags entfernen (für reine Text-Felder)
*/
function stripHtml(input) {
if (typeof input !== 'string') return input;
return sanitizeHtml(input, {
allowedTags: [],
allowedAttributes: {}
}).trim();
}
/**
* Markdown-sichere Bereinigung (erlaubt bestimmte Tags)
*/
function sanitizeMarkdown(input) {
if (typeof input !== 'string') return input;
return sanitizeHtml(input, {
allowedTags: [
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
],
allowedAttributes: {
'a': ['href', 'title', 'target', 'rel']
},
allowedSchemes: ['http', 'https', 'mailto'],
transformTags: {
'a': (tagName, attribs) => {
return {
tagName: 'a',
attribs: {
...attribs,
target: '_blank',
rel: 'noopener noreferrer'
}
};
}
}
});
}
/**
* Objekt rekursiv bereinigen
*/
function sanitizeObject(obj, options = {}) {
if (obj === null || obj === undefined) return obj;
if (typeof obj === 'string') {
return options.allowHtml ? sanitizeMarkdown(obj) : stripHtml(obj);
}
if (Array.isArray(obj)) {
return obj.map(item => sanitizeObject(item, options));
}
if (typeof obj === 'object') {
const sanitized = {};
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 });
}
return sanitized;
}
return obj;
}
/**
* Validierungsfunktionen
*/
const validators = {
/**
* Pflichtfeld prüfen
*/
required: (value, fieldName) => {
if (value === undefined || value === null || value === '') {
return `${fieldName} ist erforderlich`;
}
return null;
},
/**
* Minimale Länge prüfen
*/
minLength: (value, min, fieldName) => {
if (typeof value === 'string' && value.length < min) {
return `${fieldName} muss mindestens ${min} Zeichen haben`;
}
return null;
},
/**
* Maximale Länge prüfen
*/
maxLength: (value, max, fieldName) => {
if (typeof value === 'string' && value.length > max) {
return `${fieldName} darf maximal ${max} Zeichen haben`;
}
return null;
},
/**
* E-Mail-Format prüfen
*/
email: (value, fieldName) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (value && !emailRegex.test(value)) {
return `${fieldName} muss eine gültige E-Mail-Adresse sein`;
}
return null;
},
/**
* URL-Format prüfen
*/
url: (value, fieldName) => {
try {
if (value) {
new URL(value);
}
return null;
} catch {
return `${fieldName} muss eine gültige URL sein`;
}
},
/**
* Integer prüfen
*/
integer: (value, fieldName) => {
if (value !== undefined && value !== null && !Number.isInteger(Number(value))) {
return `${fieldName} muss eine ganze Zahl sein`;
}
return null;
},
/**
* Positiver Integer prüfen
*/
positiveInteger: (value, fieldName) => {
const num = Number(value);
if (value !== undefined && value !== null && (!Number.isInteger(num) || num < 0)) {
return `${fieldName} muss eine positive ganze Zahl sein`;
}
return null;
},
/**
* Datum prüfen (YYYY-MM-DD)
*/
date: (value, fieldName) => {
if (value) {
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
if (!dateRegex.test(value)) {
return `${fieldName} muss im Format YYYY-MM-DD sein`;
}
const date = new Date(value);
if (isNaN(date.getTime())) {
return `${fieldName} ist kein gültiges Datum`;
}
}
return null;
},
/**
* Enum-Wert prüfen
*/
enum: (value, allowedValues, fieldName) => {
if (value && !allowedValues.includes(value)) {
return `${fieldName} muss einer der folgenden Werte sein: ${allowedValues.join(', ')}`;
}
return null;
},
/**
* Hex-Farbe prüfen
*/
hexColor: (value, fieldName) => {
if (value) {
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
if (!hexRegex.test(value)) {
return `${fieldName} muss eine gültige Hex-Farbe sein (z.B. #FF0000)`;
}
}
return null;
}
};
/**
* Passwort-Richtlinien prüfen
*/
function validatePassword(password) {
const errors = [];
const minLength = 8;
if (!password || password.length < minLength) {
errors.push(`Passwort muss mindestens ${minLength} Zeichen haben`);
}
if (password && !/[a-z]/.test(password)) {
errors.push('Passwort muss mindestens einen Kleinbuchstaben enthalten');
}
if (password && !/[A-Z]/.test(password)) {
errors.push('Passwort muss mindestens einen Großbuchstaben enthalten');
}
if (password && !/[0-9]/.test(password)) {
errors.push('Passwort muss mindestens eine Zahl enthalten');
}
return errors;
}
/**
* Express Middleware: Request-Body bereinigen
*/
function sanitizeMiddleware(req, res, next) {
if (req.body && typeof req.body === 'object') {
req.body = sanitizeObject(req.body);
}
if (req.query && typeof req.query === 'object') {
req.query = sanitizeObject(req.query);
}
if (req.params && typeof req.params === 'object') {
req.params = sanitizeObject(req.params);
}
next();
}
module.exports = {
stripHtml,
sanitizeMarkdown,
sanitizeObject,
validators,
validatePassword,
sanitizeMiddleware
};

2021
backend/package-lock.json generiert Normale Datei

Datei-Diff unterdrückt, da er zu groß ist Diff laden

30
backend/package.json Normale Datei
Datei anzeigen

@ -0,0 +1,30 @@
{
"name": "taskmate",
"version": "1.0.0",
"description": "TaskMate - Aufgaben einfach verwalten",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "node --watch server.js"
},
"dependencies": {
"express": "^4.18.2",
"socket.io": "^4.7.2",
"better-sqlite3": "^9.2.2",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"uuid": "^9.0.1",
"helmet": "^7.1.0",
"cors": "^2.8.5",
"cookie-parser": "^1.4.6",
"express-rate-limiter": "^1.3.1",
"sanitize-html": "^2.11.0",
"marked": "^11.1.0"
},
"engines": {
"node": ">=18.0.0"
},
"author": "IntelSight",
"license": "PROPRIETARY"
}

409
backend/routes/admin.js Normale Datei
Datei anzeigen

@ -0,0 +1,409 @@
/**
* TASKMATE - Admin Routes
* =======================
* API-Endpunkte für Benutzerverwaltung
*/
const express = require('express');
const bcrypt = require('bcryptjs');
const router = express.Router();
const { getDb } = require('../database');
const { authenticateToken, requireAdmin } = require('../middleware/auth');
const logger = require('../utils/logger');
/**
* Standard-Upload-Einstellungen
*/
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']
}
}
};
// Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle
router.use(authenticateToken);
router.use(requireAdmin);
/**
* GET /api/admin/users - Alle Benutzer abrufen
*/
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
FROM users
ORDER BY id
`).all();
// Permissions parsen
const parsedUsers = users.map(user => ({
...user,
permissions: JSON.parse(user.permissions || '[]')
}));
res.json(parsedUsers);
} catch (error) {
logger.error('Fehler beim Abrufen der Benutzer:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Benutzer' });
}
});
/**
* POST /api/admin/users - Neuen Benutzer anlegen
*/
router.post('/users', async (req, res) => {
try {
const { username, password, displayName, email, role, permissions } = req.body;
// Validierung
if (!username || !password || !displayName || !email) {
return res.status(400).json({ error: 'Kürzel, Passwort, Anzeigename und E-Mail erforderlich' });
}
// E-Mail-Validierung
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' });
}
// Kürzel muss genau 2 Buchstaben sein
const usernameUpper = username.toUpperCase();
if (!/^[A-Z]{2}$/.test(usernameUpper)) {
return res.status(400).json({ error: 'Kürzel muss genau 2 Buchstaben sein (z.B. HG)' });
}
if (password.length < 8) {
return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben' });
}
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' });
}
// Passwort hashen
const passwordHash = await bcrypt.hash(password, 12);
// Standardfarbe Grau
const defaultColor = '#808080';
// Benutzer erstellen
const result = db.prepare(`
INSERT INTO users (username, password_hash, display_name, color, role, permissions, email)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
usernameUpper,
passwordHash,
displayName,
defaultColor,
role || 'user',
JSON.stringify(permissions || []),
email.toLowerCase()
);
logger.info(`Admin ${req.user.username} hat Benutzer ${usernameUpper} erstellt`);
res.status(201).json({
id: result.lastInsertRowid,
username: usernameUpper,
displayName,
email: email.toLowerCase(),
color: defaultColor,
role: role || 'user',
permissions: permissions || []
});
} catch (error) {
logger.error('Fehler beim Erstellen des Benutzers:', error);
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' });
}
});
/**
* PUT /api/admin/users/:id - Benutzer bearbeiten
*/
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 db = getDb();
// Benutzer prüfen
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
}
// Verhindern, dass der einzige Admin seine Rolle ändert
if (user.role === 'admin' && role !== 'admin') {
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get();
if (adminCount.count <= 1) {
return res.status(400).json({ error: 'Mindestens ein Admin muss existieren' });
}
}
// Update-Felder sammeln
const updates = [];
const params = [];
if (displayName !== undefined) {
updates.push('display_name = ?');
params.push(displayName);
}
if (color !== undefined) {
updates.push('color = ?');
params.push(color);
}
if (role !== undefined) {
updates.push('role = ?');
params.push(role);
}
if (permissions !== undefined) {
updates.push('permissions = ?');
params.push(JSON.stringify(permissions));
}
if (password) {
if (password.length < 8) {
return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben' });
}
const passwordHash = await bcrypt.hash(password, 12);
updates.push('password_hash = ?');
params.push(passwordHash);
}
if (unlockAccount) {
updates.push('failed_attempts = 0');
updates.push('locked_until = NULL');
}
if (email !== undefined) {
// E-Mail-Validierung
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' });
}
// Prüfen ob E-Mail bereits von anderem Benutzer verwendet wird
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email.toLowerCase(), userId);
if (existingEmail) {
return res.status(400).json({ error: 'E-Mail bereits vergeben' });
}
updates.push('email = ?');
params.push(email.toLowerCase());
}
if (updates.length === 0) {
return res.status(400).json({ error: 'Keine Änderungen angegeben' });
}
params.push(userId);
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
logger.info(`Admin ${req.user.username} hat Benutzer ${user.username} bearbeitet`);
// Aktualisierten Benutzer zurueckgeben
const updatedUser = db.prepare(`
SELECT id, username, display_name, color, role, permissions, email,
created_at, last_login, failed_attempts, locked_until
FROM users WHERE id = ?
`).get(userId);
res.json({
...updatedUser,
permissions: JSON.parse(updatedUser.permissions || '[]')
});
} catch (error) {
logger.error('Fehler beim Bearbeiten des Benutzers:', error);
res.status(500).json({ error: 'Fehler beim Bearbeiten des Benutzers' });
}
});
/**
* DELETE /api/admin/users/:id - Benutzer löschen
*/
router.delete('/users/:id', (req, res) => {
try {
const userId = parseInt(req.params.id);
const db = getDb();
// Benutzer prüfen
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
}
// Verhindern, dass der letzte Admin gelöscht wird
if (user.role === 'admin') {
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get();
if (adminCount.count <= 1) {
return res.status(400).json({ error: 'Der letzte Admin kann nicht gelöscht werden' });
}
}
// Verhindern, dass man sich selbst löscht
if (userId === req.user.id) {
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
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);
// Historie
db.prepare('UPDATE history SET user_id = NULL WHERE user_id = ?').run(userId);
// Vorschläge
db.prepare('UPDATE proposals SET created_by = NULL WHERE created_by = ?').run(userId);
db.prepare('UPDATE proposals SET approved_by = NULL WHERE approved_by = ?').run(userId);
// Projekte
db.prepare('UPDATE projects SET created_by = NULL WHERE created_by = ?').run(userId);
// Anhänge
db.prepare('UPDATE attachments SET uploaded_by = NULL WHERE uploaded_by = ?').run(userId);
// Links
db.prepare('UPDATE links SET created_by = NULL WHERE created_by = ?').run(userId);
// Login-Audit (kann gelöscht werden)
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);
// Benutzer löschen
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
logger.info(`Admin ${req.user.username} hat Benutzer ${user.username} gelöscht`);
res.json({ success: true });
} catch (error) {
logger.error('Fehler beim Löschen des Benutzers:', error);
res.status(500).json({ error: 'Fehler beim Löschen des Benutzers' });
}
});
/**
* GET /api/admin/upload-settings - Upload-Einstellungen abrufen
*/
router.get('/upload-settings', (req, res) => {
try {
const db = getDb();
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings');
if (setting) {
const settings = JSON.parse(setting.value);
res.json(settings);
} else {
// Standard-Einstellungen zurückgeben und speichern
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
.run('upload_settings', JSON.stringify(DEFAULT_UPLOAD_SETTINGS));
res.json(DEFAULT_UPLOAD_SETTINGS);
}
} catch (error) {
logger.error('Fehler beim Abrufen der Upload-Einstellungen:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Upload-Einstellungen' });
}
});
/**
* PUT /api/admin/upload-settings - Upload-Einstellungen speichern
*/
router.put('/upload-settings', (req, res) => {
try {
const { maxFileSizeMB, allowedTypes } = 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' });
}
const settings = { maxFileSizeMB, allowedTypes };
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`);
res.json(settings);
} catch (error) {
logger.error('Fehler beim Speichern der Upload-Einstellungen:', error);
res.status(500).json({ error: 'Fehler beim Speichern der Upload-Einstellungen' });
}
});
/**
* Hilfsfunktion zum Abrufen der aktuellen Upload-Einstellungen
*/
function getUploadSettings() {
try {
const db = getDb();
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings');
if (setting) {
return JSON.parse(setting.value);
}
return DEFAULT_UPLOAD_SETTINGS;
} catch (error) {
logger.error('Fehler beim Abrufen der Upload-Einstellungen:', error);
return DEFAULT_UPLOAD_SETTINGS;
}
}
module.exports = router;
module.exports.getUploadSettings = getUploadSettings;
module.exports.DEFAULT_UPLOAD_SETTINGS = DEFAULT_UPLOAD_SETTINGS;

212
backend/routes/applications.js Normale Datei
Datei anzeigen

@ -0,0 +1,212 @@
/**
* TASKMATE - Applications Route
* ==============================
* API-Endpoints für Anwendungs-/Repository-Verwaltung
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const gitService = require('../services/gitService');
/**
* GET /api/applications/:projectId
* Anwendungs-Konfiguration für ein Projekt abrufen
*/
router.get('/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const db = getDb();
const application = db.prepare(`
SELECT a.*, p.name as project_name
FROM applications a
JOIN projects p ON a.project_id = p.id
WHERE a.project_id = ?
`).get(projectId);
if (!application) {
return res.json({
configured: false,
projectId: parseInt(projectId)
});
}
// Prüfe ob das Repository existiert und erreichbar ist
const isRepo = gitService.isGitRepository(application.local_path);
const isAccessible = gitService.isPathAccessible(application.local_path);
res.json({
configured: true,
...application,
isRepository: isRepo,
isAccessible
});
} catch (error) {
logger.error('Fehler beim Abrufen der Anwendung:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/applications
* Anwendung für ein Projekt erstellen oder aktualisieren
*/
router.post('/', (req, res) => {
try {
const { projectId, localPath, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch } = req.body;
const userId = req.user.id;
const db = getDb();
if (!projectId || !localPath) {
return res.status(400).json({ error: 'projectId und localPath sind erforderlich' });
}
// Prüfe ob der Pfad erreichbar ist
if (!gitService.isPathAccessible(localPath)) {
return res.status(400).json({
error: 'Pfad nicht erreichbar. Stelle sicher, dass das Laufwerk in Docker gemountet ist.',
hint: 'Gemountete Laufwerke: C:, D:, E:'
});
}
// Prüfe ob bereits eine Anwendung für dieses Projekt existiert
const existing = db.prepare('SELECT id FROM applications WHERE project_id = ?').get(projectId);
if (existing) {
// Update
db.prepare(`
UPDATE applications SET
local_path = ?,
gitea_repo_url = ?,
gitea_repo_owner = ?,
gitea_repo_name = ?,
default_branch = ?
WHERE project_id = ?
`).run(localPath, giteaRepoUrl || null, giteaRepoOwner || null, giteaRepoName || null, defaultBranch || 'main', projectId);
logger.info(`Anwendung aktualisiert für Projekt ${projectId}`);
} else {
// Insert
db.prepare(`
INSERT INTO applications (project_id, local_path, gitea_repo_url, gitea_repo_owner, gitea_repo_name, default_branch, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(projectId, localPath, giteaRepoUrl || null, giteaRepoOwner || null, giteaRepoName || null, defaultBranch || 'main', userId);
logger.info(`Anwendung erstellt für Projekt ${projectId}`);
}
// Anwendung zurückgeben
const application = db.prepare(`
SELECT a.*, p.name as project_name
FROM applications a
JOIN projects p ON a.project_id = p.id
WHERE a.project_id = ?
`).get(projectId);
res.json({
success: true,
application
});
} catch (error) {
logger.error('Fehler beim Speichern der Anwendung:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* DELETE /api/applications/:projectId
* Anwendungs-Konfiguration entfernen
*/
router.delete('/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const db = getDb();
const result = db.prepare('DELETE FROM applications WHERE project_id = ?').run(projectId);
if (result.changes === 0) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt gefunden' });
}
logger.info(`Anwendung gelöscht für Projekt ${projectId}`);
res.json({ success: true });
} catch (error) {
logger.error('Fehler beim Löschen der Anwendung:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* GET /api/applications/user/base-path
* Basis-Pfad des aktuellen Benutzers abrufen
*/
router.get('/user/base-path', (req, res) => {
try {
const userId = req.user.id;
const db = getDb();
const user = db.prepare('SELECT repositories_base_path FROM users WHERE id = ?').get(userId);
res.json({
basePath: user?.repositories_base_path || null,
configured: !!user?.repositories_base_path
});
} catch (error) {
logger.error('Fehler beim Abrufen des Basis-Pfads:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* PUT /api/applications/user/base-path
* Basis-Pfad des aktuellen Benutzers setzen
*/
router.put('/user/base-path', (req, res) => {
try {
const { basePath } = req.body;
const userId = req.user.id;
const db = getDb();
if (!basePath) {
return res.status(400).json({ error: 'basePath ist erforderlich' });
}
// Prüfe ob der Pfad erreichbar ist
if (!gitService.isPathAccessible(basePath)) {
return res.status(400).json({
error: 'Pfad nicht erreichbar. Stelle sicher, dass das Laufwerk in Docker gemountet ist.',
hint: 'Gemountete Laufwerke: C:, D:, E:'
});
}
db.prepare('UPDATE users SET repositories_base_path = ? WHERE id = ?').run(basePath, userId);
logger.info(`Basis-Pfad gesetzt für Benutzer ${userId}: ${basePath}`);
res.json({ success: true, basePath });
} catch (error) {
logger.error('Fehler beim Setzen des Basis-Pfads:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/applications/:projectId/sync
* Synchronisierungszeitpunkt aktualisieren
*/
router.post('/:projectId/sync', (req, res) => {
try {
const { projectId } = req.params;
const db = getDb();
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
res.json({ success: true });
} catch (error) {
logger.error('Fehler beim Aktualisieren der Synchronisierung:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
module.exports = router;

319
backend/routes/auth.js Normale Datei
Datei anzeigen

@ -0,0 +1,319 @@
/**
* TASKMATE - Auth Routes
* ======================
* Login, Logout, Token-Refresh
*/
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const { getDb } = require('../database');
const { generateToken, authenticateToken } = require('../middleware/auth');
const { getTokenForUser } = require('../middleware/csrf');
const { validatePassword } = require('../middleware/validation');
const logger = require('../utils/logger');
// Konfiguration
const MAX_LOGIN_ATTEMPTS = parseInt(process.env.MAX_LOGIN_ATTEMPTS) || 5;
const LOCKOUT_DURATION = (parseInt(process.env.LOCKOUT_DURATION_MINUTES) || 15) * 60 * 1000;
/**
* POST /api/auth/login
* Benutzer anmelden
* - Admin-User loggt sich mit username "admin" ein
* - Alle anderen User loggen sich mit E-Mail ein
*/
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const ip = req.ip || req.connection.remoteAddress;
const userAgent = req.headers['user-agent'];
if (!username || !password) {
return res.status(400).json({ error: 'E-Mail/Benutzername und Passwort erforderlich' });
}
const db = getDb();
// Benutzer suchen: Zuerst nach Username "admin", dann nach E-Mail
let user;
if (username.toLowerCase() === 'admin') {
// Admin-User per Username suchen
user = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
} else {
// Normale User per E-Mail suchen
user = db.prepare('SELECT * FROM users WHERE email = ?').get(username);
}
// Audit-Log Eintrag vorbereiten
const logAttempt = (userId, success) => {
db.prepare(`
INSERT INTO login_audit (user_id, ip_address, success, user_agent)
VALUES (?, ?, ?, ?)
`).run(userId, ip, success ? 1 : 0, userAgent);
};
if (!user) {
logger.warn(`Login fehlgeschlagen: Benutzer nicht gefunden - ${username}`);
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
}
// Prüfen ob Account gesperrt ist
if (user.locked_until) {
const lockedUntil = new Date(user.locked_until).getTime();
if (Date.now() < lockedUntil) {
const remainingMinutes = Math.ceil((lockedUntil - Date.now()) / 60000);
logger.warn(`Login blockiert: Account gesperrt - ${username}`);
return res.status(423).json({
error: `Account ist gesperrt. Versuche es in ${remainingMinutes} Minuten erneut.`
});
} else {
// Sperre aufheben
db.prepare('UPDATE users SET locked_until = NULL, failed_attempts = 0 WHERE id = ?')
.run(user.id);
}
}
// Passwort prüfen
const validPassword = await bcrypt.compare(password, user.password_hash);
if (!validPassword) {
// Fehlversuche erhöhen
const newFailedAttempts = (user.failed_attempts || 0) + 1;
if (newFailedAttempts >= MAX_LOGIN_ATTEMPTS) {
// Account sperren
const lockUntil = new Date(Date.now() + LOCKOUT_DURATION).toISOString();
db.prepare('UPDATE users SET failed_attempts = ?, locked_until = ? WHERE id = ?')
.run(newFailedAttempts, lockUntil, user.id);
logger.warn(`Account gesperrt nach ${MAX_LOGIN_ATTEMPTS} Fehlversuchen: ${username}`);
} else {
db.prepare('UPDATE users SET failed_attempts = ? WHERE id = ?')
.run(newFailedAttempts, user.id);
}
logAttempt(user.id, false);
logger.warn(`Login fehlgeschlagen: Falsches Passwort - ${username} (Versuch ${newFailedAttempts})`);
const remainingAttempts = MAX_LOGIN_ATTEMPTS - newFailedAttempts;
return res.status(401).json({
error: 'Ungültige Anmeldedaten',
remainingAttempts: remainingAttempts > 0 ? remainingAttempts : 0
});
}
// Login erfolgreich - Fehlversuche zurücksetzen
db.prepare(`
UPDATE users
SET failed_attempts = 0, locked_until = NULL, last_login = CURRENT_TIMESTAMP
WHERE id = ?
`).run(user.id);
logAttempt(user.id, true);
// JWT-Token generieren
const token = generateToken(user);
// CSRF-Token generieren
const csrfToken = getTokenForUser(user.id);
logger.info(`Login erfolgreich: ${username}`);
// Permissions parsen
let permissions = [];
try {
permissions = JSON.parse(user.permissions || '[]');
} catch (e) {
permissions = [];
}
res.json({
token,
csrfToken,
user: {
id: user.id,
username: user.username,
displayName: user.display_name,
color: user.color,
role: user.role || 'user',
permissions: permissions
}
});
} catch (error) {
logger.error('Login-Fehler:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/auth/logout
* Benutzer abmelden
*/
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' });
});
/**
* GET /api/auth/me
* Aktuellen Benutzer abrufen
*/
router.get('/me', authenticateToken, (req, res) => {
try {
const db = getDb();
const user = db.prepare('SELECT id, username, display_name, color, role, permissions FROM users WHERE id = ?')
.get(req.user.id);
if (!user) {
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
}
// CSRF-Token erneuern
const csrfToken = getTokenForUser(user.id);
// Permissions parsen
let permissions = [];
try {
permissions = JSON.parse(user.permissions || '[]');
} catch (e) {
permissions = [];
}
res.json({
user: {
id: user.id,
username: user.username,
displayName: user.display_name,
color: user.color,
role: user.role || 'user',
permissions: permissions
},
csrfToken
});
} catch (error) {
logger.error('Fehler bei /me:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/auth/refresh
* Token erneuern
*/
router.post('/refresh', authenticateToken, (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 token = generateToken(user);
const csrfToken = getTokenForUser(user.id);
res.json({ token, csrfToken });
} catch (error) {
logger.error('Token-Refresh Fehler:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/auth/password
* Passwort ändern
*/
router.put('/password', authenticateToken, async (req, res) => {
try {
const { currentPassword, newPassword } = req.body;
if (!currentPassword || !newPassword) {
return res.status(400).json({ error: 'Aktuelles und neues Passwort erforderlich' });
}
// Passwort-Richtlinien prüfen
const passwordErrors = validatePassword(newPassword);
if (passwordErrors.length > 0) {
return res.status(400).json({ error: passwordErrors.join('. ') });
}
const db = getDb();
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
// Aktuelles Passwort prüfen
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
if (!validPassword) {
return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
}
// Neues Passwort hashen und speichern
const newHash = await bcrypt.hash(newPassword, 12);
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newHash, user.id);
logger.info(`Passwort geändert: ${user.username}`);
res.json({ message: 'Passwort erfolgreich geändert' });
} catch (error) {
logger.error('Passwort-Änderung Fehler:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/auth/color
* Benutzerfarbe ändern
*/
router.put('/color', authenticateToken, (req, res) => {
try {
const { color } = req.body;
if (!color) {
return res.status(400).json({ error: 'Farbe erforderlich' });
}
// Validate hex color format
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
if (!hexColorRegex.test(color)) {
return res.status(400).json({ error: 'Ungültiges Farbformat (erwartet: #RRGGBB)' });
}
const db = getDb();
db.prepare('UPDATE users SET color = ? WHERE id = ?').run(color, req.user.id);
logger.info(`Farbe geändert: ${req.user.username} -> ${color}`);
res.json({ message: 'Farbe erfolgreich geändert', color });
} catch (error) {
logger.error('Fehler beim Ändern der Farbe:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/auth/users
* Alle Benutzer abrufen (für Zuweisung)
* Admin-Benutzer werden ausgeschlossen, da sie nur fuer die Benutzerverwaltung sind
*/
router.get('/users', authenticateToken, (req, res) => {
try {
const db = getDb();
// Nur regulaere Benutzer (nicht Admins) fuer Aufgaben-Zuweisung
const users = db.prepare(`
SELECT id, username, display_name, color
FROM users
WHERE role != 'admin' OR role IS NULL
`).all();
res.json(users.map(u => ({
id: u.id,
username: u.username,
displayName: u.display_name,
color: u.color
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Benutzer:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

302
backend/routes/columns.js Normale Datei
Datei anzeigen

@ -0,0 +1,302 @@
/**
* TASKMATE - Column Routes
* ========================
* CRUD für Board-Spalten
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators } = require('../middleware/validation');
/**
* GET /api/columns/:projectId
* Alle Spalten eines Projekts
*/
router.get('/:projectId', (req, res) => {
try {
const db = getDb();
const columns = db.prepare(`
SELECT * FROM columns WHERE project_id = ? ORDER BY position
`).all(req.params.projectId);
res.json(columns.map(c => ({
id: c.id,
projectId: c.project_id,
name: c.name,
position: c.position,
color: c.color,
filterCategory: c.filter_category || 'in_progress'
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Spalten:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/columns
* Neue Spalte erstellen
*/
router.post('/', (req, res) => {
try {
const { projectId, name, color, filterCategory } = req.body;
// Validierung
const errors = [];
errors.push(validators.required(projectId, 'Projekt-ID'));
errors.push(validators.required(name, 'Name'));
errors.push(validators.maxLength(name, 50, 'Name'));
if (color) errors.push(validators.hexColor(color, 'Farbe'));
const firstError = errors.find(e => e !== null);
if (firstError) {
return res.status(400).json({ error: firstError });
}
const db = getDb();
// Höchste Position ermitteln
const maxPos = db.prepare(
'SELECT COALESCE(MAX(position), -1) as max FROM columns WHERE project_id = ?'
).get(projectId).max;
// Spalte erstellen mit filter_category
const result = db.prepare(`
INSERT INTO columns (project_id, name, position, color, filter_category)
VALUES (?, ?, ?, ?, ?)
`).run(projectId, name, maxPos + 1, color || null, filterCategory || 'in_progress');
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(result.lastInsertRowid);
logger.info(`Spalte erstellt: ${name} in Projekt ${projectId} (Filter: ${filterCategory || 'in_progress'})`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${projectId}`).emit('column:created', {
id: column.id,
projectId: column.project_id,
name: column.name,
position: column.position,
color: column.color,
filterCategory: column.filter_category
});
res.status(201).json({
id: column.id,
projectId: column.project_id,
name: column.name,
position: column.position,
color: column.color,
filterCategory: column.filter_category
});
} catch (error) {
logger.error('Fehler beim Erstellen der Spalte:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/columns/:id
* Spalte aktualisieren
*/
router.put('/:id', (req, res) => {
try {
const columnId = req.params.id;
const { name, color, filterCategory } = req.body;
// Validierung
if (name) {
const nameError = 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();
const existing = db.prepare('SELECT * FROM columns WHERE id = ?').get(columnId);
if (!existing) {
return res.status(404).json({ error: 'Spalte nicht gefunden' });
}
db.prepare(`
UPDATE columns
SET name = COALESCE(?, name), color = ?, filter_category = COALESCE(?, filter_category)
WHERE id = ?
`).run(name || null, color !== undefined ? color : existing.color, filterCategory || null, columnId);
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(columnId);
logger.info(`Spalte aktualisiert: ${column.name} (ID: ${columnId})`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${column.project_id}`).emit('column:updated', {
id: column.id,
name: column.name,
color: column.color,
filterCategory: column.filter_category
});
res.json({
id: column.id,
projectId: column.project_id,
name: column.name,
position: column.position,
color: column.color,
filterCategory: column.filter_category
});
} catch (error) {
logger.error('Fehler beim Aktualisieren der Spalte:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/columns/:id/position
* Spalten-Position ändern (Reihenfolge)
*/
router.put('/:id/position', (req, res) => {
try {
const columnId = req.params.id;
const { newPosition } = req.body;
if (typeof newPosition !== 'number' || newPosition < 0) {
return res.status(400).json({ error: 'Ungültige Position' });
}
const db = getDb();
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(columnId);
if (!column) {
return res.status(404).json({ error: 'Spalte nicht gefunden' });
}
const oldPosition = column.position;
const projectId = column.project_id;
// Positionen der anderen Spalten anpassen
if (newPosition > oldPosition) {
// Nach rechts verschoben: Spalten dazwischen nach links
db.prepare(`
UPDATE columns
SET position = position - 1
WHERE project_id = ? AND position > ? AND position <= ?
`).run(projectId, oldPosition, newPosition);
} else if (newPosition < oldPosition) {
// Nach links verschoben: Spalten dazwischen nach rechts
db.prepare(`
UPDATE columns
SET position = position + 1
WHERE project_id = ? AND position >= ? AND position < ?
`).run(projectId, newPosition, oldPosition);
}
// Neue Position setzen
db.prepare('UPDATE columns SET position = ? WHERE id = ?').run(newPosition, columnId);
// Alle Spalten des Projekts zurückgeben
const columns = db.prepare(
'SELECT * FROM columns WHERE project_id = ? ORDER BY position'
).all(projectId);
logger.info(`Spalte ${column.name} von Position ${oldPosition} zu ${newPosition} verschoben`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${projectId}`).emit('columns:reordered', {
columns: columns.map(c => ({
id: c.id,
name: c.name,
position: c.position,
color: c.color,
filterCategory: c.filter_category
}))
});
res.json({
columns: columns.map(c => ({
id: c.id,
projectId: c.project_id,
name: c.name,
position: c.position,
color: c.color,
filterCategory: c.filter_category
}))
});
} catch (error) {
logger.error('Fehler beim Verschieben der Spalte:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/columns/:id
* Spalte löschen
*/
router.delete('/:id', (req, res) => {
try {
const columnId = req.params.id;
const db = getDb();
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(columnId);
if (!column) {
return res.status(404).json({ error: 'Spalte nicht gefunden' });
}
// Prüfen ob Aufgaben in der Spalte sind
const taskCount = db.prepare(
'SELECT COUNT(*) as count FROM tasks WHERE column_id = ?'
).get(columnId).count;
if (taskCount > 0) {
return res.status(400).json({
error: 'Spalte enthält noch Aufgaben. Verschiebe oder lösche diese zuerst.'
});
}
// Mindestens eine Spalte muss bleiben
const columnCount = db.prepare(
'SELECT COUNT(*) as count FROM columns WHERE project_id = ?'
).get(column.project_id).count;
if (columnCount <= 1) {
return res.status(400).json({
error: 'Mindestens eine Spalte muss vorhanden sein.'
});
}
// Spalte löschen
db.prepare('DELETE FROM columns WHERE id = ?').run(columnId);
// Positionen neu nummerieren
const remainingColumns = db.prepare(
'SELECT id FROM columns WHERE project_id = ? ORDER BY position'
).all(column.project_id);
remainingColumns.forEach((col, index) => {
db.prepare('UPDATE columns SET position = ? WHERE id = ?').run(index, col.id);
});
logger.info(`Spalte gelöscht: ${column.name} (ID: ${columnId})`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${column.project_id}`).emit('column:deleted', { id: columnId });
res.json({ message: 'Spalte gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen der Spalte:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

279
backend/routes/comments.js Normale Datei
Datei anzeigen

@ -0,0 +1,279 @@
/**
* TASKMATE - Comment Routes
* =========================
* CRUD für Kommentare
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators, sanitizeMarkdown } = require('../middleware/validation');
const notificationService = require('../services/notificationService');
/**
* GET /api/comments/:taskId
* Alle Kommentare einer Aufgabe
*/
router.get('/:taskId', (req, res) => {
try {
const db = getDb();
const comments = db.prepare(`
SELECT c.*, u.display_name, u.color
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.task_id = ?
ORDER BY c.created_at ASC
`).all(req.params.taskId);
res.json(comments.map(c => ({
id: c.id,
taskId: c.task_id,
userId: c.user_id,
userName: c.display_name,
userColor: c.color,
content: c.content,
createdAt: c.created_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Kommentare:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/comments
* Neuen Kommentar erstellen
*/
router.post('/', (req, res) => {
try {
const { taskId, content } = req.body;
// Validierung
const contentError = validators.required(content, 'Inhalt') ||
validators.maxLength(content, 5000, 'Inhalt');
if (contentError) {
return res.status(400).json({ error: contentError });
}
const db = getDb();
// Task prüfen
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!task) {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
// Inhalt bereinigen (Markdown erlaubt)
const sanitizedContent = sanitizeMarkdown(content);
// @Erwähnungen verarbeiten
const mentions = content.match(/@(\w+)/g);
const mentionedUsers = [];
if (mentions) {
mentions.forEach(mention => {
const username = mention.substring(1);
const user = db.prepare('SELECT id, display_name FROM users WHERE username = ?').get(username);
if (user) {
mentionedUsers.push(user);
}
});
}
// Kommentar erstellen
const result = db.prepare(`
INSERT INTO comments (task_id, user_id, content)
VALUES (?, ?, ?)
`).run(taskId, req.user.id, sanitizedContent);
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
// Historie
db.prepare(`
INSERT INTO history (task_id, user_id, action, new_value)
VALUES (?, ?, 'commented', ?)
`).run(taskId, req.user.id, sanitizedContent.substring(0, 100));
const comment = db.prepare(`
SELECT c.*, u.display_name, u.color
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.id = ?
`).get(result.lastInsertRowid);
logger.info(`Kommentar erstellt in Task ${taskId} von ${req.user.username}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('comment:created', {
taskId,
comment: {
id: comment.id,
taskId: comment.task_id,
userId: comment.user_id,
userName: comment.display_name,
userColor: comment.color,
content: comment.content,
createdAt: comment.created_at
},
mentionedUsers
});
// Benachrichtigungen senden
// 1. Benachrichtigung an zugewiesene Mitarbeiter der Aufgabe
const assignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
const mentionedUserIds = mentionedUsers.map(u => u.id);
assignees.forEach(a => {
// Nicht an Kommentator und nicht an erwähnte Benutzer (die bekommen separate Benachrichtigung)
if (a.user_id !== req.user.id && !mentionedUserIds.includes(a.user_id)) {
notificationService.create(a.user_id, 'comment:created', {
taskId: parseInt(taskId),
taskTitle: task.title,
projectId: task.project_id,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
});
// 2. Benachrichtigung an erwähnte Benutzer
mentionedUsers.forEach(user => {
if (user.id !== req.user.id) {
notificationService.create(user.id, 'comment:mention', {
taskId: parseInt(taskId),
taskTitle: task.title,
projectId: task.project_id,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
});
res.status(201).json({
id: comment.id,
taskId: comment.task_id,
userId: comment.user_id,
userName: comment.display_name,
userColor: comment.color,
content: comment.content,
createdAt: comment.created_at
});
} catch (error) {
logger.error('Fehler beim Erstellen des Kommentars:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/comments/:id
* Kommentar bearbeiten (nur eigene)
*/
router.put('/:id', (req, res) => {
try {
const commentId = req.params.id;
const { content } = req.body;
// Validierung
const contentError = validators.required(content, 'Inhalt') ||
validators.maxLength(content, 5000, 'Inhalt');
if (contentError) {
return res.status(400).json({ error: contentError });
}
const db = getDb();
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId);
if (!comment) {
return res.status(404).json({ error: 'Kommentar nicht gefunden' });
}
// Nur eigene Kommentare bearbeiten
if (comment.user_id !== req.user.id) {
return res.status(403).json({ error: 'Nur eigene Kommentare können bearbeitet werden' });
}
const sanitizedContent = sanitizeMarkdown(content);
db.prepare('UPDATE comments SET content = ? WHERE id = ?')
.run(sanitizedContent, commentId);
const updated = db.prepare(`
SELECT c.*, u.display_name, u.color
FROM comments c
JOIN users u ON c.user_id = u.id
WHERE c.id = ?
`).get(commentId);
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(comment.task_id);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('comment:updated', {
taskId: comment.task_id,
comment: {
id: updated.id,
taskId: updated.task_id,
userId: updated.user_id,
userName: updated.display_name,
userColor: updated.color,
content: updated.content,
createdAt: updated.created_at
}
});
res.json({
id: updated.id,
taskId: updated.task_id,
userId: updated.user_id,
userName: updated.display_name,
userColor: updated.color,
content: updated.content,
createdAt: updated.created_at
});
} catch (error) {
logger.error('Fehler beim Aktualisieren des Kommentars:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/comments/:id
* Kommentar löschen (nur eigene)
*/
router.delete('/:id', (req, res) => {
try {
const commentId = req.params.id;
const db = getDb();
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId);
if (!comment) {
return res.status(404).json({ error: 'Kommentar nicht gefunden' });
}
// Nur eigene Kommentare löschen
if (comment.user_id !== req.user.id) {
return res.status(403).json({ error: 'Nur eigene Kommentare können gelöscht werden' });
}
db.prepare('DELETE FROM comments WHERE id = ?').run(commentId);
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(comment.task_id);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('comment:deleted', {
taskId: comment.task_id,
commentId
});
res.json({ message: 'Kommentar gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen des Kommentars:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

230
backend/routes/export.js Normale Datei
Datei anzeigen

@ -0,0 +1,230 @@
/**
* TASKMATE - Export Routes
* ========================
* Export in JSON und CSV
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
/**
* GET /api/export/project/:id/json
* Projekt als JSON exportieren
*/
router.get('/project/:id/json', (req, res) => {
try {
const projectId = req.params.id;
const db = getDb();
// Projekt
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
if (!project) {
return res.status(404).json({ error: 'Projekt nicht gefunden' });
}
// Spalten
const columns = db.prepare('SELECT * FROM columns WHERE project_id = ? ORDER BY position').all(projectId);
// Labels
const labels = db.prepare('SELECT * FROM labels WHERE project_id = ?').all(projectId);
// Aufgaben mit allen Details
const tasks = db.prepare('SELECT * FROM tasks WHERE project_id = ?').all(projectId);
const tasksWithDetails = tasks.map(task => {
const taskLabels = db.prepare(`
SELECT l.* FROM labels l
JOIN task_labels tl ON l.id = tl.label_id
WHERE tl.task_id = ?
`).all(task.id);
const subtasks = db.prepare('SELECT * FROM subtasks WHERE task_id = ? ORDER BY position').all(task.id);
const comments = db.prepare(`
SELECT c.*, u.display_name FROM comments c
LEFT JOIN users u ON c.user_id = u.id
WHERE c.task_id = ?
`).all(task.id);
const attachments = db.prepare('SELECT * FROM attachments WHERE task_id = ?').all(task.id);
const links = db.prepare('SELECT * FROM links WHERE task_id = ?').all(task.id);
return {
...task,
labels: taskLabels,
subtasks,
comments,
attachments,
links
};
});
// Vorlagen
const templates = db.prepare('SELECT * FROM task_templates WHERE project_id = ?').all(projectId);
const exportData = {
exportedAt: new Date().toISOString(),
exportedBy: req.user.username,
version: '1.0',
project: {
id: project.id,
name: project.name,
description: project.description,
createdAt: project.created_at
},
columns: columns.map(c => ({
id: c.id,
name: c.name,
position: c.position,
color: c.color
})),
labels: labels.map(l => ({
id: l.id,
name: l.name,
color: l.color
})),
tasks: tasksWithDetails,
templates
};
logger.info(`Projekt exportiert als JSON: ${project.name}`);
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${project.name.replace(/[^a-z0-9]/gi, '_')}_export.json"`);
res.json(exportData);
} catch (error) {
logger.error('Fehler beim JSON-Export:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/export/project/:id/csv
* Aufgaben als CSV exportieren
*/
router.get('/project/:id/csv', (req, res) => {
try {
const projectId = req.params.id;
const db = getDb();
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
if (!project) {
return res.status(404).json({ error: 'Projekt nicht gefunden' });
}
const tasks = db.prepare(`
SELECT
t.*,
c.name as column_name,
u.display_name as assigned_name
FROM tasks t
LEFT JOIN columns c ON t.column_id = c.id
LEFT JOIN users u ON t.assigned_to = u.id
WHERE t.project_id = ?
ORDER BY c.position, t.position
`).all(projectId);
// CSV Header
const headers = [
'ID', 'Titel', 'Beschreibung', 'Status', 'Priorität',
'Fälligkeitsdatum', 'Zugewiesen an', 'Zeitschätzung (Min)',
'Erstellt am', 'Archiviert'
];
// CSV Zeilen
const rows = tasks.map(task => [
task.id,
escapeCsvField(task.title),
escapeCsvField(task.description || ''),
task.column_name,
task.priority,
task.due_date || '',
task.assigned_name || '',
task.time_estimate_min || '',
task.created_at,
task.archived ? 'Ja' : 'Nein'
]);
// CSV zusammenbauen
const csv = [
headers.join(';'),
...rows.map(row => row.join(';'))
].join('\n');
logger.info(`Projekt exportiert als CSV: ${project.name}`);
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${project.name.replace(/[^a-z0-9]/gi, '_')}_export.csv"`);
// BOM für Excel UTF-8 Erkennung
res.send('\ufeff' + csv);
} catch (error) {
logger.error('Fehler beim CSV-Export:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/export/all/json
* Alle Daten exportieren (Backup)
*/
router.get('/all/json', (req, res) => {
try {
const db = getDb();
const projects = db.prepare('SELECT * FROM projects').all();
const columns = db.prepare('SELECT * FROM columns').all();
const labels = db.prepare('SELECT * FROM labels').all();
const tasks = db.prepare('SELECT * FROM tasks').all();
const subtasks = db.prepare('SELECT * FROM subtasks').all();
const comments = db.prepare('SELECT * FROM comments').all();
const taskLabels = db.prepare('SELECT * FROM task_labels').all();
const attachments = db.prepare('SELECT * FROM attachments').all();
const links = db.prepare('SELECT * FROM links').all();
const templates = db.prepare('SELECT * FROM task_templates').all();
const history = db.prepare('SELECT * FROM history').all();
const exportData = {
exportedAt: new Date().toISOString(),
exportedBy: req.user.username,
version: '1.0',
data: {
projects,
columns,
labels,
tasks,
subtasks,
comments,
taskLabels,
attachments,
links,
templates,
history
}
};
logger.info('Vollständiger Export durchgeführt');
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="taskmate_backup_${Date.now()}.json"`);
res.json(exportData);
} catch (error) {
logger.error('Fehler beim Voll-Export:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* Hilfsfunktion: CSV-Feld escapen
*/
function escapeCsvField(field) {
if (typeof field !== 'string') return field;
// Wenn Feld Semikolon, Anführungszeichen oder Zeilenumbruch enthält
if (field.includes(';') || field.includes('"') || field.includes('\n')) {
// Anführungszeichen verdoppeln und in Anführungszeichen setzen
return '"' + field.replace(/"/g, '""') + '"';
}
return field;
}
module.exports = router;

238
backend/routes/files.js Normale Datei
Datei anzeigen

@ -0,0 +1,238 @@
/**
* TASKMATE - File Routes
* ======================
* Upload, Download, Löschen von Dateien
*/
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs');
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { upload, deleteFile, formatFileSize, isImage, getFileIcon, UPLOAD_DIR } = require('../middleware/upload');
const csrfProtection = require('../middleware/csrf');
/**
* GET /api/files/:taskId
* Alle Dateien einer Aufgabe
*/
router.get('/:taskId', (req, res) => {
try {
const db = getDb();
const attachments = db.prepare(`
SELECT a.*, u.display_name as uploader_name
FROM attachments a
LEFT JOIN users u ON a.uploaded_by = u.id
WHERE a.task_id = ?
ORDER BY a.uploaded_at DESC
`).all(req.params.taskId);
res.json(attachments.map(a => ({
id: a.id,
taskId: a.task_id,
filename: a.filename,
originalName: a.original_name,
mimeType: a.mime_type,
sizeBytes: a.size_bytes,
sizeFormatted: formatFileSize(a.size_bytes),
isImage: isImage(a.mime_type),
icon: getFileIcon(a.mime_type),
uploadedBy: a.uploaded_by,
uploaderName: a.uploader_name,
uploadedAt: a.uploaded_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Dateien:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/files/:taskId
* Datei(en) hochladen
*/
router.post('/:taskId', csrfProtection, upload.array('files', 10), (req, res) => {
try {
const taskId = req.params.taskId;
const db = getDb();
// Task prüfen
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!task) {
// Hochgeladene Dateien löschen
req.files?.forEach(f => fs.unlinkSync(f.path));
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
if (!req.files || req.files.length === 0) {
return res.status(400).json({ error: 'Keine Dateien hochgeladen' });
}
const insertAttachment = db.prepare(`
INSERT INTO attachments (task_id, filename, original_name, mime_type, size_bytes, uploaded_by)
VALUES (?, ?, ?, ?, ?, ?)
`);
const attachments = [];
req.files.forEach(file => {
const result = insertAttachment.run(
taskId,
`task_${taskId}/${file.filename}`,
file.originalname,
file.mimetype,
file.size,
req.user.id
);
attachments.push({
id: result.lastInsertRowid,
taskId: parseInt(taskId),
filename: `task_${taskId}/${file.filename}`,
originalName: file.originalname,
mimeType: file.mimetype,
sizeBytes: file.size,
sizeFormatted: formatFileSize(file.size),
isImage: isImage(file.mimetype),
icon: getFileIcon(file.mimetype),
uploadedBy: req.user.id,
uploaderName: req.user.displayName,
uploadedAt: new Date().toISOString()
});
});
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
// Historie
db.prepare(`
INSERT INTO history (task_id, user_id, action, new_value)
VALUES (?, ?, 'attachment_added', ?)
`).run(taskId, req.user.id, attachments.map(a => a.originalName).join(', '));
logger.info(`${attachments.length} Datei(en) hochgeladen für Task ${taskId}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('files:uploaded', {
taskId,
attachments
});
res.status(201).json({ attachments });
} catch (error) {
logger.error('Fehler beim Hochladen:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/files/download/:id
* Datei herunterladen
*/
router.get('/download/:id', (req, res) => {
try {
const db = getDb();
const attachment = db.prepare('SELECT * FROM attachments WHERE id = ?').get(req.params.id);
if (!attachment) {
return res.status(404).json({ error: 'Datei nicht gefunden' });
}
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (!fs.existsSync(filePath)) {
logger.error(`Datei existiert nicht: ${filePath}`);
return res.status(404).json({ error: 'Datei nicht gefunden' });
}
res.download(filePath, attachment.original_name);
} catch (error) {
logger.error('Fehler beim Download:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/files/preview/:id
* Bild-Vorschau
*/
router.get('/preview/:id', (req, res) => {
try {
const db = getDb();
const attachment = db.prepare('SELECT * FROM attachments WHERE id = ?').get(req.params.id);
if (!attachment) {
return res.status(404).json({ error: 'Datei nicht gefunden' });
}
if (!isImage(attachment.mime_type)) {
return res.status(400).json({ error: 'Keine Bilddatei' });
}
const filePath = path.join(UPLOAD_DIR, attachment.filename);
if (!fs.existsSync(filePath)) {
return res.status(404).json({ error: 'Datei nicht gefunden' });
}
res.setHeader('Content-Type', attachment.mime_type);
res.sendFile(filePath);
} catch (error) {
logger.error('Fehler bei Vorschau:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/files/:id
* Datei löschen
*/
router.delete('/:id', csrfProtection, (req, res) => {
try {
const attachmentId = req.params.id;
const db = getDb();
const attachment = db.prepare('SELECT * FROM attachments WHERE id = ?').get(attachmentId);
if (!attachment) {
return res.status(404).json({ error: 'Datei nicht gefunden' });
}
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(attachment.task_id);
// 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 attachments WHERE id = ?').run(attachmentId);
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(attachment.task_id);
// Historie
db.prepare(`
INSERT INTO history (task_id, user_id, action, old_value)
VALUES (?, ?, 'attachment_removed', ?)
`).run(attachment.task_id, req.user.id, attachment.original_name);
logger.info(`Datei gelöscht: ${attachment.original_name}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('file:deleted', {
taskId: attachment.task_id,
attachmentId
});
res.json({ message: 'Datei gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen der Datei:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

444
backend/routes/git.js Normale Datei
Datei anzeigen

@ -0,0 +1,444 @@
/**
* TASKMATE - Git Route
* =====================
* API-Endpoints für Git-Operationen
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const gitService = require('../services/gitService');
const giteaService = require('../services/giteaService');
/**
* Hilfsfunktion: Anwendung für Projekt abrufen
*/
function getApplicationForProject(projectId) {
const db = getDb();
return db.prepare('SELECT * FROM applications WHERE project_id = ?').get(projectId);
}
/**
* POST /api/git/clone
* Repository klonen
*/
router.post('/clone', async (req, res) => {
try {
const { projectId, repoUrl, localPath, branch } = req.body;
if (!localPath) {
return res.status(400).json({ error: 'localPath ist erforderlich' });
}
if (!repoUrl) {
return res.status(400).json({ error: 'repoUrl ist erforderlich' });
}
// Clone ausführen
const result = await gitService.cloneRepository(repoUrl, localPath, { branch });
if (result.success && projectId) {
// Anwendung aktualisieren
const db = getDb();
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
}
res.json(result);
} catch (error) {
logger.error('Fehler beim Klonen:', error);
res.status(500).json({ error: 'Serverfehler', details: error.message });
}
});
/**
* GET /api/git/status/:projectId
* Git-Status für ein Projekt abrufen
*/
router.get('/status/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
const result = gitService.getStatus(application.local_path);
res.json(result);
} catch (error) {
logger.error('Fehler beim Abrufen des Status:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/pull/:projectId
* Pull für ein Projekt ausführen
*/
router.post('/pull/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const { branch } = req.body;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
// Fetch zuerst
gitService.fetchRemote(application.local_path);
// Dann Pull
const result = gitService.pullChanges(application.local_path, { branch });
if (result.success) {
// Sync-Zeitpunkt aktualisieren
const db = getDb();
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
}
res.json(result);
} catch (error) {
logger.error('Fehler beim Pull:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/push/:projectId
* Push für ein Projekt ausführen
*/
router.post('/push/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const { branch } = req.body;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
// Prüfe ob Remote existiert
if (!gitService.hasRemote(application.local_path)) {
return res.json({
success: false,
error: 'Kein Remote konfiguriert. Bitte Repository zuerst vorbereiten.'
});
}
// Versuche normalen Push, falls das fehlschlägt wegen fehlendem Upstream, push mit -u
let result = gitService.pushChanges(application.local_path, { branch });
// Falls Push wegen fehlendem Upstream fehlschlägt, versuche mit -u
if (!result.success && result.error && result.error.includes('no upstream')) {
const currentBranch = branch || 'main';
result = gitService.pushWithUpstream(application.local_path, currentBranch);
}
if (result.success) {
// Sync-Zeitpunkt aktualisieren
const db = getDb();
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
}
res.json(result);
} catch (error) {
logger.error('Fehler beim Push:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/commit/:projectId
* Commit für ein Projekt erstellen
*/
router.post('/commit/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const { message, stageAll } = req.body;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
if (!message) {
return res.status(400).json({ error: 'Commit-Nachricht ist erforderlich' });
}
// Optional: Alle Änderungen stagen
if (stageAll !== false) {
const stageResult = gitService.stageAll(application.local_path);
if (!stageResult.success) {
return res.json(stageResult);
}
}
// Commit erstellen
const result = gitService.commit(application.local_path, message);
res.json(result);
} catch (error) {
logger.error('Fehler beim Commit:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* GET /api/git/commits/:projectId
* Commit-Historie für ein Projekt abrufen
*/
router.get('/commits/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const limit = parseInt(req.query.limit) || 20;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
const result = gitService.getCommitHistory(application.local_path, limit);
res.json(result);
} catch (error) {
logger.error('Fehler beim Abrufen der Commits:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* GET /api/git/branches/:projectId
* Branches für ein Projekt abrufen
*/
router.get('/branches/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
const result = gitService.getBranches(application.local_path);
res.json(result);
} catch (error) {
logger.error('Fehler beim Abrufen der Branches:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/checkout/:projectId
* Branch wechseln
*/
router.post('/checkout/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const { branch } = req.body;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
if (!branch) {
return res.status(400).json({ error: 'Branch ist erforderlich' });
}
const result = gitService.checkoutBranch(application.local_path, branch);
res.json(result);
} catch (error) {
logger.error('Fehler beim Branch-Wechsel:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/fetch/:projectId
* Fetch von Remote ausführen
*/
router.post('/fetch/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
const result = gitService.fetchRemote(application.local_path);
res.json(result);
} catch (error) {
logger.error('Fehler beim Fetch:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/stage/:projectId
* Alle Änderungen stagen
*/
router.post('/stage/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
const result = gitService.stageAll(application.local_path);
res.json(result);
} catch (error) {
logger.error('Fehler beim Stagen:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* GET /api/git/remote/:projectId
* Remote-URL abrufen
*/
router.get('/remote/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
const result = gitService.getRemoteUrl(application.local_path);
res.json(result);
} catch (error) {
logger.error('Fehler beim Abrufen der Remote-URL:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/validate-path
* Pfad validieren
*/
router.post('/validate-path', (req, res) => {
try {
const { path } = req.body;
if (!path) {
return res.status(400).json({ error: 'Pfad ist erforderlich' });
}
const isAccessible = gitService.isPathAccessible(path);
const isRepo = isAccessible ? gitService.isGitRepository(path) : false;
const hasRemote = isRepo ? gitService.hasRemote(path) : false;
res.json({
valid: isAccessible,
isRepository: isRepo,
hasRemote: hasRemote,
containerPath: gitService.windowsToContainerPath(path)
});
} catch (error) {
logger.error('Fehler bei der Pfad-Validierung:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/prepare/:projectId
* Repository für Gitea vorbereiten (init, remote hinzufügen)
*/
router.post('/prepare/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const { repoUrl, branch } = req.body;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
if (!repoUrl) {
return res.status(400).json({ error: 'repoUrl ist erforderlich' });
}
const result = gitService.prepareForGitea(application.local_path, repoUrl, { branch });
if (result.success) {
logger.info(`Repository vorbereitet für Projekt ${projectId}: ${repoUrl}`);
}
res.json(result);
} catch (error) {
logger.error('Fehler beim Vorbereiten des Repositories:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/set-remote/:projectId
* Remote für ein Projekt setzen/aktualisieren
*/
router.post('/set-remote/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const { repoUrl } = req.body;
const application = getApplicationForProject(projectId);
if (!application) {
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
}
if (!repoUrl) {
return res.status(400).json({ error: 'repoUrl ist erforderlich' });
}
// Prüfe ob Git-Repo existiert
if (!gitService.isGitRepository(application.local_path)) {
// Initialisiere Repository
const initResult = gitService.initRepository(application.local_path);
if (!initResult.success) {
return res.json(initResult);
}
}
// Remote setzen
const result = gitService.setRemote(application.local_path, repoUrl);
res.json(result);
} catch (error) {
logger.error('Fehler beim Setzen des Remotes:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
/**
* POST /api/git/init-push/:projectId
* Initialen Push mit Upstream-Tracking
*/
router.post('/init-push/:projectId', (req, res) => {
try {
const { projectId } = req.params;
const { branch } = 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);
if (result.success) {
// Sync-Zeitpunkt aktualisieren
const db = getDb();
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
}
res.json(result);
} catch (error) {
logger.error('Fehler beim initialen Push:', error);
res.status(500).json({ error: 'Serverfehler' });
}
});
module.exports = router;

160
backend/routes/gitea.js Normale Datei
Datei anzeigen

@ -0,0 +1,160 @@
/**
* TASKMATE - Gitea Route
* ======================
* API-Endpoints für Gitea-Integration
*/
const express = require('express');
const router = express.Router();
const giteaService = require('../services/giteaService');
const logger = require('../utils/logger');
/**
* GET /api/gitea/test
* Gitea-Verbindung testen
*/
router.get('/test', async (req, res) => {
try {
const result = await giteaService.testConnection();
res.json(result);
} catch (error) {
logger.error('Fehler beim Testen der Gitea-Verbindung:', error);
res.status(500).json({
success: false,
connected: false,
error: error.message
});
}
});
/**
* GET /api/gitea/repositories
* Alle verfügbaren Repositories auflisten
*/
router.get('/repositories', async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const result = await giteaService.listRepositories({ page, limit });
res.json(result);
} catch (error) {
logger.error('Fehler beim Auflisten der Repositories:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* POST /api/gitea/repositories
* Neues Repository erstellen
*/
router.post('/repositories', async (req, res) => {
try {
const { name, description, private: isPrivate, autoInit, defaultBranch } = req.body;
if (!name) {
return res.status(400).json({ error: 'Repository-Name ist erforderlich' });
}
const result = await giteaService.createRepository(name, {
description,
private: isPrivate !== false,
autoInit: autoInit !== false,
defaultBranch: defaultBranch || 'main'
});
if (result.success) {
logger.info(`Gitea-Repository erstellt: ${result.repository.fullName}`);
}
res.json(result);
} catch (error) {
logger.error('Fehler beim Erstellen des Repositories:', error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/gitea/repositories/:owner/:repo
* Repository-Details abrufen
*/
router.get('/repositories/:owner/:repo', async (req, res) => {
try {
const { owner, repo } = req.params;
const result = await giteaService.getRepository(owner, repo);
res.json(result);
} catch (error) {
logger.error(`Fehler beim Abrufen des Repositories ${req.params.owner}/${req.params.repo}:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* DELETE /api/gitea/repositories/:owner/:repo
* Repository löschen
*/
router.delete('/repositories/:owner/:repo', async (req, res) => {
try {
const { owner, repo } = req.params;
const result = await giteaService.deleteRepository(owner, repo);
if (result.success) {
logger.info(`Gitea-Repository gelöscht: ${owner}/${repo}`);
}
res.json(result);
} catch (error) {
logger.error(`Fehler beim Löschen des Repositories ${req.params.owner}/${req.params.repo}:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/gitea/repositories/:owner/:repo/branches
* Branches eines Repositories abrufen
*/
router.get('/repositories/:owner/:repo/branches', async (req, res) => {
try {
const { owner, repo } = req.params;
const result = await giteaService.getRepositoryBranches(owner, repo);
res.json(result);
} catch (error) {
logger.error(`Fehler beim Abrufen der Branches für ${req.params.owner}/${req.params.repo}:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/gitea/repositories/:owner/:repo/commits
* Commits eines Repositories abrufen
*/
router.get('/repositories/:owner/:repo/commits', async (req, res) => {
try {
const { owner, repo } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const branch = req.query.branch || '';
const result = await giteaService.getRepositoryCommits(owner, repo, { page, limit, branch });
res.json(result);
} catch (error) {
logger.error(`Fehler beim Abrufen der Commits für ${req.params.owner}/${req.params.repo}:`, error);
res.status(500).json({ success: false, error: error.message });
}
});
/**
* GET /api/gitea/user
* Aktuellen Gitea-Benutzer abrufen
*/
router.get('/user', async (req, res) => {
try {
const result = await giteaService.getCurrentUser();
res.json(result);
} catch (error) {
logger.error('Fehler beim Abrufen des Gitea-Benutzers:', error);
res.status(500).json({ success: false, error: error.message });
}
});
module.exports = router;

158
backend/routes/health.js Normale Datei
Datei anzeigen

@ -0,0 +1,158 @@
/**
* TASKMATE - Health Check Routes
* ==============================
* Server-Status und Health-Check Endpoints
*/
const express = require('express');
const router = express.Router();
const fs = require('fs');
const path = require('path');
const { getDb } = require('../database');
const backup = require('../utils/backup');
/**
* GET /api/health
* Einfacher Health-Check
*/
router.get('/', (req, res) => {
try {
// Datenbank-Check
const db = getDb();
db.prepare('SELECT 1').get();
res.json({
status: 'healthy',
timestamp: new Date().toISOString()
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
/**
* GET /api/health/detailed
* Detaillierter Health-Check (mit Auth)
*/
router.get('/detailed', (req, res) => {
try {
const db = getDb();
// Datenbank-Statistiken
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const projectCount = db.prepare('SELECT COUNT(*) as count FROM projects WHERE archived = 0').get().count;
const taskCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE archived = 0').get().count;
// Disk-Space für Uploads
const uploadsDir = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
let uploadsSize = 0;
let uploadCount = 0;
if (fs.existsSync(uploadsDir)) {
const getDirectorySize = (dir) => {
let size = 0;
let count = 0;
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stats = fs.statSync(filePath);
if (stats.isDirectory()) {
const subResult = getDirectorySize(filePath);
size += subResult.size;
count += subResult.count;
} else {
size += stats.size;
count++;
}
}
return { size, count };
};
const result = getDirectorySize(uploadsDir);
uploadsSize = result.size;
uploadCount = result.count;
}
// Letzte Backups
const backups = backup.listBackups().slice(0, 5);
// Memory Usage
const memUsage = process.memoryUsage();
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: Math.floor(process.uptime()),
database: {
users: userCount,
projects: projectCount,
tasks: taskCount
},
storage: {
uploadCount,
uploadsSizeMB: Math.round(uploadsSize / 1024 / 1024 * 100) / 100
},
backups: backups.map(b => ({
name: b.name,
sizeMB: Math.round(b.size / 1024 / 1024 * 100) / 100,
created: b.created
})),
memory: {
heapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024 * 100) / 100,
heapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024 * 100) / 100,
rssMB: Math.round(memUsage.rss / 1024 / 1024 * 100) / 100
},
environment: process.env.NODE_ENV || 'development'
});
} catch (error) {
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
/**
* POST /api/health/backup
* Manuelles Backup auslösen
*/
router.post('/backup', (req, res) => {
try {
const backupPath = backup.createBackup();
if (backupPath) {
res.json({
message: 'Backup erfolgreich erstellt',
path: path.basename(backupPath)
});
} else {
res.status(500).json({ error: 'Backup fehlgeschlagen' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
/**
* GET /api/health/backups
* Liste aller Backups
*/
router.get('/backups', (req, res) => {
try {
const backups = backup.listBackups();
res.json(backups.map(b => ({
name: b.name,
sizeMB: Math.round(b.size / 1024 / 1024 * 100) / 100,
created: b.created
})));
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

269
backend/routes/import.js Normale Datei
Datei anzeigen

@ -0,0 +1,269 @@
/**
* TASKMATE - Import Routes
* ========================
* Import von JSON-Backups
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
/**
* POST /api/import/project
* Projekt aus JSON importieren
*/
router.post('/project', (req, res) => {
try {
const { data, overwrite = false } = req.body;
if (!data || !data.project) {
return res.status(400).json({ error: 'Ungültiges Import-Format' });
}
const db = getDb();
// Transaktion starten
const importProject = db.transaction(() => {
const importData = data;
// Projekt erstellen
const projectResult = db.prepare(`
INSERT INTO projects (name, description, created_by)
VALUES (?, ?, ?)
`).run(
importData.project.name + (overwrite ? '' : ' (Import)'),
importData.project.description,
req.user.id
);
const newProjectId = projectResult.lastInsertRowid;
// Mapping für alte -> neue IDs
const columnMap = new Map();
const labelMap = new Map();
const taskMap = new Map();
// Spalten importieren
if (importData.columns) {
const insertColumn = db.prepare(`
INSERT INTO columns (project_id, name, position, color)
VALUES (?, ?, ?, ?)
`);
importData.columns.forEach(col => {
const result = insertColumn.run(newProjectId, col.name, col.position, col.color);
columnMap.set(col.id, result.lastInsertRowid);
});
}
// Labels importieren
if (importData.labels) {
const insertLabel = db.prepare(`
INSERT INTO labels (project_id, name, color)
VALUES (?, ?, ?)
`);
importData.labels.forEach(label => {
const result = insertLabel.run(newProjectId, label.name, label.color);
labelMap.set(label.id, result.lastInsertRowid);
});
}
// Aufgaben importieren
if (importData.tasks) {
const insertTask = db.prepare(`
INSERT INTO tasks (
project_id, column_id, title, description, priority,
due_date, time_estimate_min, position, created_by
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
importData.tasks.forEach(task => {
const newColumnId = columnMap.get(task.column_id);
if (!newColumnId) return;
const result = insertTask.run(
newProjectId,
newColumnId,
task.title,
task.description,
task.priority || 'medium',
task.due_date,
task.time_estimate_min,
task.position,
req.user.id
);
taskMap.set(task.id, result.lastInsertRowid);
// Task-Labels
if (task.labels) {
const insertTaskLabel = db.prepare(
'INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)'
);
task.labels.forEach(label => {
const newLabelId = labelMap.get(label.id);
if (newLabelId) {
try {
insertTaskLabel.run(result.lastInsertRowid, newLabelId);
} catch (e) { /* Ignorieren */ }
}
});
}
// Subtasks
if (task.subtasks) {
const insertSubtask = db.prepare(
'INSERT INTO subtasks (task_id, title, completed, position) VALUES (?, ?, ?, ?)'
);
task.subtasks.forEach(st => {
insertSubtask.run(
result.lastInsertRowid,
st.title,
st.completed ? 1 : 0,
st.position
);
});
}
// Links
if (task.links) {
const insertLink = db.prepare(
'INSERT INTO links (task_id, title, url, created_by) VALUES (?, ?, ?, ?)'
);
task.links.forEach(link => {
insertLink.run(result.lastInsertRowid, link.title, link.url, req.user.id);
});
}
});
}
// Vorlagen importieren
if (importData.templates) {
const insertTemplate = db.prepare(`
INSERT INTO task_templates (
project_id, name, title_template, description,
priority, labels, subtasks, time_estimate_min
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
importData.templates.forEach(tmpl => {
// Labels-IDs mappen
let newLabels = null;
if (tmpl.labels) {
const oldLabels = typeof tmpl.labels === 'string' ? JSON.parse(tmpl.labels) : tmpl.labels;
const mappedLabels = oldLabels.map(id => labelMap.get(id)).filter(id => id);
newLabels = JSON.stringify(mappedLabels);
}
insertTemplate.run(
newProjectId,
tmpl.name,
tmpl.title_template,
tmpl.description,
tmpl.priority,
newLabels,
tmpl.subtasks,
tmpl.time_estimate_min
);
});
}
return {
projectId: newProjectId,
columnsImported: columnMap.size,
labelsImported: labelMap.size,
tasksImported: taskMap.size
};
});
const result = importProject();
logger.info(`Projekt importiert: ID ${result.projectId} (${result.tasksImported} Aufgaben)`);
res.status(201).json({
message: 'Import erfolgreich',
...result
});
} catch (error) {
logger.error('Fehler beim Import:', { error: error.message });
res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message });
}
});
/**
* POST /api/import/validate
* Import-Datei validieren
*/
router.post('/validate', (req, res) => {
try {
const { data } = req.body;
const errors = [];
const warnings = [];
if (!data) {
errors.push('Keine Daten vorhanden');
return res.json({ valid: false, errors, warnings });
}
// Version prüfen
if (!data.version) {
warnings.push('Keine Versionsangabe gefunden');
}
// Projekt prüfen
if (!data.project) {
errors.push('Kein Projekt in den Daten gefunden');
} else {
if (!data.project.name) {
errors.push('Projektname fehlt');
}
}
// Spalten prüfen
if (!data.columns || data.columns.length === 0) {
errors.push('Keine Spalten in den Daten gefunden');
}
// Aufgaben prüfen
if (data.tasks) {
data.tasks.forEach((task, idx) => {
if (!task.title) {
warnings.push(`Aufgabe ${idx + 1} hat keinen Titel`);
}
if (!task.column_id) {
warnings.push(`Aufgabe "${task.title || idx + 1}" hat keine Spalten-ID`);
}
});
}
// Statistiken
const stats = {
projectName: data.project?.name || 'Unbekannt',
columns: data.columns?.length || 0,
labels: data.labels?.length || 0,
tasks: data.tasks?.length || 0,
templates: data.templates?.length || 0
};
res.json({
valid: errors.length === 0,
errors,
warnings,
stats
});
} catch (error) {
logger.error('Fehler bei Import-Validierung:', { error: error.message });
res.status(400).json({
valid: false,
errors: ['Ungültiges JSON-Format'],
warnings: []
});
}
});
module.exports = router;

202
backend/routes/labels.js Normale Datei
Datei anzeigen

@ -0,0 +1,202 @@
/**
* TASKMATE - Label Routes
* =======================
* CRUD für Labels/Tags
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators } = require('../middleware/validation');
/**
* GET /api/labels/:projectId
* Alle Labels eines Projekts
*/
router.get('/:projectId', (req, res) => {
try {
const db = getDb();
const labels = db.prepare(`
SELECT l.*,
(SELECT COUNT(*) FROM task_labels tl WHERE tl.label_id = l.id) as task_count
FROM labels l
WHERE l.project_id = ?
ORDER BY l.name
`).all(req.params.projectId);
res.json(labels.map(l => ({
id: l.id,
projectId: l.project_id,
name: l.name,
color: l.color,
taskCount: l.task_count
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Labels:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/labels
* Neues Label erstellen
*/
router.post('/', (req, res) => {
try {
const { projectId, name, color } = req.body;
// Validierung
const errors = [];
errors.push(validators.required(projectId, 'Projekt-ID'));
errors.push(validators.required(name, 'Name'));
errors.push(validators.maxLength(name, 30, 'Name'));
errors.push(validators.required(color, 'Farbe'));
errors.push(validators.hexColor(color, 'Farbe'));
const firstError = errors.find(e => e !== null);
if (firstError) {
return res.status(400).json({ error: firstError });
}
const db = getDb();
// Prüfen ob Label-Name bereits existiert
const existing = db.prepare(
'SELECT id FROM labels WHERE project_id = ? AND LOWER(name) = LOWER(?)'
).get(projectId, name);
if (existing) {
return res.status(400).json({ error: 'Ein Label mit diesem Namen existiert bereits' });
}
const result = db.prepare(`
INSERT INTO labels (project_id, name, color)
VALUES (?, ?, ?)
`).run(projectId, name, color);
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(result.lastInsertRowid);
logger.info(`Label erstellt: ${name} in Projekt ${projectId}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${projectId}`).emit('label:created', {
id: label.id,
projectId: label.project_id,
name: label.name,
color: label.color
});
res.status(201).json({
id: label.id,
projectId: label.project_id,
name: label.name,
color: label.color,
taskCount: 0
});
} catch (error) {
logger.error('Fehler beim Erstellen des Labels:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/labels/:id
* Label aktualisieren
*/
router.put('/:id', (req, res) => {
try {
const labelId = req.params.id;
const { name, color } = req.body;
// Validierung
if (name) {
const nameError = validators.maxLength(name, 30, '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();
const existing = db.prepare('SELECT * FROM labels WHERE id = ?').get(labelId);
if (!existing) {
return res.status(404).json({ error: 'Label nicht gefunden' });
}
// Prüfen ob neuer Name bereits existiert
if (name && name.toLowerCase() !== existing.name.toLowerCase()) {
const duplicate = db.prepare(
'SELECT id FROM labels WHERE project_id = ? AND LOWER(name) = LOWER(?) AND id != ?'
).get(existing.project_id, name, labelId);
if (duplicate) {
return res.status(400).json({ error: 'Ein Label mit diesem Namen existiert bereits' });
}
}
db.prepare(`
UPDATE labels SET
name = COALESCE(?, name),
color = COALESCE(?, color)
WHERE id = ?
`).run(name || null, color || null, labelId);
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(labelId);
logger.info(`Label aktualisiert: ${label.name} (ID: ${labelId})`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${label.project_id}`).emit('label:updated', {
id: label.id,
name: label.name,
color: label.color
});
res.json({
id: label.id,
projectId: label.project_id,
name: label.name,
color: label.color
});
} catch (error) {
logger.error('Fehler beim Aktualisieren des Labels:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/labels/:id
* Label löschen
*/
router.delete('/:id', (req, res) => {
try {
const labelId = req.params.id;
const db = getDb();
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(labelId);
if (!label) {
return res.status(404).json({ error: 'Label nicht gefunden' });
}
// Label wird von task_labels durch CASCADE gelöscht
db.prepare('DELETE FROM labels WHERE id = ?').run(labelId);
logger.info(`Label gelöscht: ${label.name} (ID: ${labelId})`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${label.project_id}`).emit('label:deleted', { id: labelId });
res.json({ message: 'Label gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen des Labels:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

253
backend/routes/links.js Normale Datei
Datei anzeigen

@ -0,0 +1,253 @@
/**
* TASKMATE - Link Routes
* ======================
* CRUD für Links/URLs
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators, stripHtml } = require('../middleware/validation');
/**
* Hilfsfunktion: Link-Icon basierend auf URL
*/
function getLinkIcon(url) {
try {
const hostname = new URL(url).hostname.toLowerCase();
if (hostname.includes('youtube') || hostname.includes('youtu.be')) return 'youtube';
if (hostname.includes('github')) return 'github';
if (hostname.includes('gitlab')) return 'gitlab';
if (hostname.includes('figma')) return 'figma';
if (hostname.includes('drive.google')) return 'google-drive';
if (hostname.includes('docs.google')) return 'google-docs';
if (hostname.includes('notion')) return 'notion';
if (hostname.includes('trello')) return 'trello';
if (hostname.includes('slack')) return 'slack';
if (hostname.includes('jira') || hostname.includes('atlassian')) return 'jira';
return 'link';
} catch {
return 'link';
}
}
/**
* GET /api/links/:taskId
* Alle Links einer Aufgabe
*/
router.get('/:taskId', (req, res) => {
try {
const db = getDb();
const links = db.prepare(`
SELECT l.*, u.display_name as creator_name
FROM links l
LEFT JOIN users u ON l.created_by = u.id
WHERE l.task_id = ?
ORDER BY l.created_at DESC
`).all(req.params.taskId);
res.json(links.map(l => ({
id: l.id,
taskId: l.task_id,
title: l.title,
url: l.url,
icon: getLinkIcon(l.url),
createdBy: l.created_by,
creatorName: l.creator_name,
createdAt: l.created_at
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Links:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/links
* Neuen Link erstellen
*/
router.post('/', (req, res) => {
try {
const { taskId, title, url } = req.body;
// Validierung
const urlError = validators.required(url, 'URL') || validators.url(url, 'URL');
if (urlError) {
return res.status(400).json({ error: urlError });
}
if (title) {
const titleError = validators.maxLength(title, 100, 'Titel');
if (titleError) {
return res.status(400).json({ error: titleError });
}
}
const db = getDb();
// Task prüfen
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!task) {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
const sanitizedTitle = title ? stripHtml(title) : null;
const result = db.prepare(`
INSERT INTO links (task_id, title, url, created_by)
VALUES (?, ?, ?, ?)
`).run(taskId, sanitizedTitle, url, req.user.id);
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
const link = db.prepare(`
SELECT l.*, u.display_name as creator_name
FROM links l
LEFT JOIN users u ON l.created_by = u.id
WHERE l.id = ?
`).get(result.lastInsertRowid);
logger.info(`Link erstellt: ${url} für Task ${taskId}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('link:created', {
taskId,
link: {
id: link.id,
taskId: link.task_id,
title: link.title,
url: link.url,
icon: getLinkIcon(link.url),
createdBy: link.created_by,
creatorName: link.creator_name,
createdAt: link.created_at
}
});
res.status(201).json({
id: link.id,
taskId: link.task_id,
title: link.title,
url: link.url,
icon: getLinkIcon(link.url),
createdBy: link.created_by,
creatorName: link.creator_name,
createdAt: link.created_at
});
} catch (error) {
logger.error('Fehler beim Erstellen des Links:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/links/:id
* Link aktualisieren
*/
router.put('/:id', (req, res) => {
try {
const linkId = req.params.id;
const { title, url } = req.body;
const db = getDb();
const existing = db.prepare('SELECT * FROM links WHERE id = ?').get(linkId);
if (!existing) {
return res.status(404).json({ error: 'Link nicht gefunden' });
}
// Validierung
if (url) {
const urlError = validators.url(url, 'URL');
if (urlError) return res.status(400).json({ error: urlError });
}
if (title) {
const titleError = validators.maxLength(title, 100, 'Titel');
if (titleError) return res.status(400).json({ error: titleError });
}
db.prepare(`
UPDATE links SET
title = ?,
url = COALESCE(?, url)
WHERE id = ?
`).run(title !== undefined ? stripHtml(title) : existing.title, url || null, linkId);
const link = db.prepare(`
SELECT l.*, u.display_name as creator_name
FROM links l
LEFT JOIN users u ON l.created_by = u.id
WHERE l.id = ?
`).get(linkId);
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(link.task_id);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('link:updated', {
taskId: link.task_id,
link: {
id: link.id,
title: link.title,
url: link.url,
icon: getLinkIcon(link.url)
}
});
res.json({
id: link.id,
taskId: link.task_id,
title: link.title,
url: link.url,
icon: getLinkIcon(link.url),
createdBy: link.created_by,
creatorName: link.creator_name,
createdAt: link.created_at
});
} catch (error) {
logger.error('Fehler beim Aktualisieren des Links:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/links/:id
* Link löschen
*/
router.delete('/:id', (req, res) => {
try {
const linkId = req.params.id;
const db = getDb();
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(linkId);
if (!link) {
return res.status(404).json({ error: 'Link nicht gefunden' });
}
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(link.task_id);
db.prepare('DELETE FROM links WHERE id = ?').run(linkId);
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(link.task_id);
logger.info(`Link gelöscht: ${link.url}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('link:deleted', {
taskId: link.task_id,
linkId
});
res.json({ message: 'Link gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen des Links:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

Datei anzeigen

@ -0,0 +1,134 @@
/**
* TASKMATE - Notifications Routes
* ================================
* API-Endpunkte für das Benachrichtigungssystem
*/
const express = require('express');
const router = express.Router();
const notificationService = require('../services/notificationService');
const logger = require('../utils/logger');
/**
* GET /api/notifications
* Alle Benachrichtigungen des Users abrufen
*/
router.get('/', (req, res) => {
try {
const userId = req.user.id;
const limit = parseInt(req.query.limit) || 50;
const notifications = notificationService.getForUser(userId, limit);
const unreadCount = notificationService.getUnreadCount(userId);
res.json({
notifications,
unreadCount
});
} catch (error) {
logger.error('Fehler beim Abrufen der Benachrichtigungen:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Benachrichtigungen' });
}
});
/**
* GET /api/notifications/count
* Ungelesene Anzahl ermitteln
*/
router.get('/count', (req, res) => {
try {
const userId = req.user.id;
const count = notificationService.getUnreadCount(userId);
res.json({ count });
} catch (error) {
logger.error('Fehler beim Ermitteln der Anzahl:', error);
res.status(500).json({ error: 'Fehler beim Ermitteln der Anzahl' });
}
});
/**
* PUT /api/notifications/:id/read
* Als gelesen markieren
*/
router.put('/:id/read', (req, res) => {
try {
const userId = req.user.id;
const notificationId = parseInt(req.params.id);
const success = notificationService.markAsRead(notificationId, userId);
if (!success) {
return res.status(404).json({ error: 'Benachrichtigung nicht gefunden' });
}
// Aktualisierte Zählung senden
const io = req.app.get('io');
const count = notificationService.getUnreadCount(userId);
if (io) {
io.to(`user:${userId}`).emit('notification:count', { count });
}
res.json({ success: true, unreadCount: count });
} catch (error) {
logger.error('Fehler beim Markieren als gelesen:', error);
res.status(500).json({ error: 'Fehler beim Markieren als gelesen' });
}
});
/**
* PUT /api/notifications/read-all
* Alle als gelesen markieren
*/
router.put('/read-all', (req, res) => {
try {
const userId = req.user.id;
const count = notificationService.markAllAsRead(userId);
// Aktualisierte Zählung senden
const io = req.app.get('io');
if (io) {
io.to(`user:${userId}`).emit('notification:count', { count: 0 });
}
res.json({ success: true, markedCount: count, unreadCount: 0 });
} catch (error) {
logger.error('Fehler beim Markieren aller als gelesen:', error);
res.status(500).json({ error: 'Fehler beim Markieren aller als gelesen' });
}
});
/**
* DELETE /api/notifications/:id
* Benachrichtigung löschen (nur nicht-persistente)
*/
router.delete('/:id', (req, res) => {
try {
const userId = req.user.id;
const notificationId = parseInt(req.params.id);
const success = notificationService.delete(notificationId, userId);
if (!success) {
return res.status(400).json({
error: 'Benachrichtigung nicht gefunden oder kann nicht gelöscht werden'
});
}
// Aktualisierte Zählung senden
const io = req.app.get('io');
const count = notificationService.getUnreadCount(userId);
if (io) {
io.to(`user:${userId}`).emit('notification:count', { count });
io.to(`user:${userId}`).emit('notification:deleted', { notificationId });
}
res.json({ success: true, unreadCount: count });
} catch (error) {
logger.error('Fehler beim Löschen der Benachrichtigung:', error);
res.status(500).json({ error: 'Fehler beim Löschen der Benachrichtigung' });
}
});
module.exports = router;

359
backend/routes/projects.js Normale Datei
Datei anzeigen

@ -0,0 +1,359 @@
/**
* TASKMATE - Project Routes
* =========================
* CRUD für Projekte
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators } = require('../middleware/validation');
/**
* GET /api/projects
* Alle Projekte abrufen
*/
router.get('/', (req, res) => {
try {
const db = getDb();
const includeArchived = req.query.archived === 'true';
let query = `
SELECT p.*, u.display_name as creator_name,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id AND t.archived = 0) as task_count,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id AND t.archived = 0 AND t.column_id IN
(SELECT c.id FROM columns c WHERE c.project_id = p.id ORDER BY c.position DESC LIMIT 1)) as completed_count
FROM projects p
LEFT JOIN users u ON p.created_by = u.id
`;
if (!includeArchived) {
query += ' WHERE p.archived = 0';
}
query += ' ORDER BY p.created_at DESC';
const projects = db.prepare(query).all();
res.json(projects.map(p => ({
id: p.id,
name: p.name,
description: p.description,
archived: !!p.archived,
createdAt: p.created_at,
createdBy: p.created_by,
creatorName: p.creator_name,
taskCount: p.task_count,
completedCount: p.completed_count
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Projekte:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/projects/:id
* Einzelnes Projekt mit Spalten und Aufgaben
*/
router.get('/:id', (req, res) => {
try {
const db = getDb();
const projectId = req.params.id;
// Projekt abrufen
const project = db.prepare(`
SELECT p.*, u.display_name as creator_name
FROM projects p
LEFT JOIN users u ON p.created_by = u.id
WHERE p.id = ?
`).get(projectId);
if (!project) {
return res.status(404).json({ error: 'Projekt nicht gefunden' });
}
// Spalten abrufen
const columns = db.prepare(`
SELECT * FROM columns WHERE project_id = ? ORDER BY position
`).all(projectId);
// Labels abrufen
const labels = db.prepare(`
SELECT * FROM labels WHERE project_id = ?
`).all(projectId);
res.json({
id: project.id,
name: project.name,
description: project.description,
archived: !!project.archived,
createdAt: project.created_at,
createdBy: project.created_by,
creatorName: project.creator_name,
columns: columns.map(c => ({
id: c.id,
name: c.name,
position: c.position,
color: c.color
})),
labels: labels.map(l => ({
id: l.id,
name: l.name,
color: l.color
}))
});
} catch (error) {
logger.error('Fehler beim Abrufen des Projekts:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/projects
* Neues Projekt erstellen
*/
router.post('/', (req, res) => {
try {
const { name, description } = req.body;
// Validierung
const nameError = validators.required(name, 'Name') ||
validators.maxLength(name, 100, 'Name');
if (nameError) {
return res.status(400).json({ error: nameError });
}
const db = getDb();
// Projekt erstellen
const result = db.prepare(`
INSERT INTO projects (name, description, created_by)
VALUES (?, ?, ?)
`).run(name, description || null, req.user.id);
const projectId = result.lastInsertRowid;
// Standard-Spalten erstellen
const insertColumn = db.prepare(`
INSERT INTO columns (project_id, name, position) VALUES (?, ?, ?)
`);
insertColumn.run(projectId, 'Offen', 0);
insertColumn.run(projectId, 'In Arbeit', 1);
insertColumn.run(projectId, 'Erledigt', 2);
// Standard-Labels erstellen
const insertLabel = db.prepare(`
INSERT INTO labels (project_id, name, color) VALUES (?, ?, ?)
`);
insertLabel.run(projectId, 'Bug', '#DC2626');
insertLabel.run(projectId, 'Feature', '#059669');
insertLabel.run(projectId, 'Dokumentation', '#3182CE');
// Projekt mit Spalten und Labels zurückgeben
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
const columns = db.prepare('SELECT * FROM columns WHERE project_id = ? ORDER BY position').all(projectId);
const labels = db.prepare('SELECT * FROM labels WHERE project_id = ?').all(projectId);
logger.info(`Projekt erstellt: ${name} (ID: ${projectId}) von ${req.user.username}`);
// WebSocket: Andere Clients benachrichtigen
const io = req.app.get('io');
io.emit('project:created', {
id: projectId,
name: project.name,
description: project.description,
createdBy: req.user.id
});
res.status(201).json({
id: project.id,
name: project.name,
description: project.description,
archived: false,
createdAt: project.created_at,
createdBy: project.created_by,
columns: columns.map(c => ({ id: c.id, name: c.name, position: c.position, color: c.color })),
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color }))
});
} catch (error) {
logger.error('Fehler beim Erstellen des Projekts:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/projects/:id
* Projekt aktualisieren
*/
router.put('/:id', (req, res) => {
try {
const projectId = req.params.id;
const { name, description } = req.body;
// Validierung
if (name) {
const nameError = validators.maxLength(name, 100, 'Name');
if (nameError) {
return res.status(400).json({ error: nameError });
}
}
const db = getDb();
// Prüfen ob Projekt existiert
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
if (!existing) {
return res.status(404).json({ error: 'Projekt nicht gefunden' });
}
// Aktualisieren
db.prepare(`
UPDATE projects
SET name = COALESCE(?, name), description = COALESCE(?, description)
WHERE id = ?
`).run(name || null, description !== undefined ? description : null, projectId);
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
logger.info(`Projekt aktualisiert: ${project.name} (ID: ${projectId})`);
// WebSocket
const io = req.app.get('io');
io.emit('project:updated', {
id: project.id,
name: project.name,
description: project.description
});
res.json({
id: project.id,
name: project.name,
description: project.description,
archived: !!project.archived,
createdAt: project.created_at
});
} catch (error) {
logger.error('Fehler beim Aktualisieren des Projekts:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/projects/:id/archive
* Projekt archivieren/wiederherstellen
*/
router.put('/:id/archive', (req, res) => {
try {
const projectId = req.params.id;
const { archived } = req.body;
const db = getDb();
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
if (!existing) {
return res.status(404).json({ error: 'Projekt nicht gefunden' });
}
db.prepare('UPDATE projects SET archived = ? WHERE id = ?')
.run(archived ? 1 : 0, projectId);
logger.info(`Projekt ${archived ? 'archiviert' : 'wiederhergestellt'}: ${existing.name}`);
// WebSocket
const io = req.app.get('io');
io.emit('project:archived', { id: projectId, archived: !!archived });
res.json({ message: archived ? 'Projekt archiviert' : 'Projekt wiederhergestellt' });
} catch (error) {
logger.error('Fehler beim Archivieren:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/projects/:id
* Projekt löschen
* Query param: force=true um alle zugehörigen Aufgaben mitzulöschen
*/
router.delete('/:id', (req, res) => {
try {
const projectId = req.params.id;
const forceDelete = req.query.force === 'true';
const db = getDb();
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
if (!project) {
return res.status(404).json({ error: 'Projekt nicht gefunden' });
}
// Anzahl der Aufgaben ermitteln
const taskCount = db.prepare(
'SELECT COUNT(*) as count FROM tasks WHERE project_id = ?'
).get(projectId).count;
// Ohne force: Prüfen ob noch aktive Aufgaben existieren
if (!forceDelete && taskCount > 0) {
return res.status(400).json({
error: 'Projekt enthält noch Aufgaben. Verwende force=true um alles zu löschen.',
taskCount: taskCount
});
}
// Bei force=true: Explizit alle zugehörigen Daten löschen
if (forceDelete && taskCount > 0) {
// Alle Task-IDs für das Projekt holen
const taskIds = db.prepare('SELECT id FROM tasks WHERE project_id = ?')
.all(projectId)
.map(t => t.id);
if (taskIds.length > 0) {
const placeholders = taskIds.map(() => '?').join(',');
// Anhänge löschen
db.prepare(`DELETE FROM attachments WHERE task_id IN (${placeholders})`).run(...taskIds);
// Kommentare löschen
db.prepare(`DELETE FROM comments WHERE task_id IN (${placeholders})`).run(...taskIds);
// Task-Labels löschen
db.prepare(`DELETE FROM task_labels WHERE task_id IN (${placeholders})`).run(...taskIds);
// Task-Assignees löschen (Mehrfachzuweisung)
db.prepare(`DELETE FROM task_assignees WHERE task_id IN (${placeholders})`).run(...taskIds);
// Unteraufgaben löschen
db.prepare(`DELETE FROM subtasks WHERE task_id IN (${placeholders})`).run(...taskIds);
// Links löschen
db.prepare(`DELETE FROM links WHERE task_id IN (${placeholders})`).run(...taskIds);
// Historie löschen
db.prepare(`DELETE FROM history WHERE task_id IN (${placeholders})`).run(...taskIds);
// Tasks löschen
db.prepare(`DELETE FROM tasks WHERE project_id = ?`).run(projectId);
}
logger.info(`${taskCount} Aufgaben gelöscht für Projekt: ${project.name}`);
}
// Labels des Projekts löschen
db.prepare('DELETE FROM labels WHERE project_id = ?').run(projectId);
// Spalten löschen
db.prepare('DELETE FROM columns WHERE project_id = ?').run(projectId);
// Projekt löschen
db.prepare('DELETE FROM projects WHERE id = ?').run(projectId);
logger.info(`Projekt gelöscht: ${project.name} (ID: ${projectId}), ${taskCount} Aufgaben entfernt`);
// WebSocket
const io = req.app.get('io');
io.emit('project:deleted', { id: projectId });
res.json({ message: 'Projekt gelöscht', deletedTasks: taskCount });
} catch (error) {
logger.error('Fehler beim Löschen des Projekts:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

299
backend/routes/proposals.js Normale Datei
Datei anzeigen

@ -0,0 +1,299 @@
/**
* TASKMATE - Proposals Routes
* ===========================
* API-Endpunkte fuer Vorschlaege und Genehmigungen
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const { authenticateToken, requireRegularUser, checkPermission } = require('../middleware/auth');
const logger = require('../utils/logger');
const notificationService = require('../services/notificationService');
// Alle Proposals-Routes erfordern Authentifizierung und regulaeren User (kein Admin)
router.use(authenticateToken);
router.use(requireRegularUser);
/**
* GET /api/proposals - Alle Genehmigungen abrufen (projektbezogen)
* Query-Parameter: sort = 'date' | 'alpha', archived = '0' | '1', projectId = number
*/
router.get('/', (req, res) => {
try {
const db = getDb();
const sort = req.query.sort || 'date';
const archived = req.query.archived === '1' ? 1 : 0;
const projectId = req.query.projectId ? parseInt(req.query.projectId) : null;
let orderBy;
switch (sort) {
case 'alpha':
orderBy = 'p.title ASC';
break;
case 'date':
default:
orderBy = 'p.created_at DESC';
break;
}
// Nur Genehmigungen des aktuellen Projekts laden
let whereClause = 'p.archived = ?';
const params = [archived];
if (projectId) {
whereClause += ' AND p.project_id = ?';
params.push(projectId);
}
const proposals = db.prepare(`
SELECT
p.*,
u.display_name as created_by_name,
u.color as created_by_color,
ua.display_name as approved_by_name,
t.title as task_title,
t.id as linked_task_id
FROM proposals p
LEFT JOIN users u ON p.created_by = u.id
LEFT JOIN users ua ON p.approved_by = ua.id
LEFT JOIN tasks t ON p.task_id = t.id
WHERE ${whereClause}
ORDER BY ${orderBy}
`).all(...params);
res.json(proposals);
} catch (error) {
logger.error('Fehler beim Abrufen der Genehmigungen:', error);
res.status(500).json({ error: 'Fehler beim Abrufen der Genehmigungen' });
}
});
/**
* POST /api/proposals - Neue Genehmigung erstellen (projektbezogen)
*/
router.post('/', (req, res) => {
try {
const { title, description, taskId, projectId } = req.body;
if (!title || title.trim().length === 0) {
return res.status(400).json({ error: 'Titel erforderlich' });
}
if (!projectId) {
return res.status(400).json({ error: 'Projekt erforderlich' });
}
const db = getDb();
const result = db.prepare(`
INSERT INTO proposals (title, description, created_by, task_id, project_id)
VALUES (?, ?, ?, ?, ?)
`).run(title.trim(), description?.trim() || null, req.user.id, taskId || null, projectId);
const proposal = db.prepare(`
SELECT
p.*,
u.display_name as created_by_name,
u.color as created_by_color,
t.title as task_title,
t.id as linked_task_id
FROM proposals p
LEFT JOIN users u ON p.created_by = u.id
LEFT JOIN tasks t ON p.task_id = t.id
WHERE p.id = ?
`).get(result.lastInsertRowid);
logger.info(`Benutzer ${req.user.username} hat Genehmigung "${title}" erstellt`);
// Benachrichtigungen an User mit 'genehmigung'-Berechtigung senden (persistent)
const io = req.app.get('io');
const usersWithPermission = db.prepare(`
SELECT id FROM users
WHERE role = 'user'
AND permissions LIKE '%genehmigung%'
AND id != ?
`).all(req.user.id);
usersWithPermission.forEach(user => {
notificationService.create(user.id, 'approval:pending', {
proposalId: proposal.id,
proposalTitle: title.trim(),
projectId: projectId,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io, true); // persistent = true
});
res.status(201).json(proposal);
} catch (error) {
logger.error('Fehler beim Erstellen der Genehmigung:', error);
res.status(500).json({ error: 'Fehler beim Erstellen der Genehmigung' });
}
});
/**
* PUT /api/proposals/:id/approve - Genehmigung erteilen (nur mit Berechtigung)
*/
router.put('/:id/approve', checkPermission('genehmigung'), (req, res) => {
try {
const proposalId = parseInt(req.params.id);
const { approved } = req.body;
const db = getDb();
// Genehmigung pruefen
const proposal = db.prepare('SELECT * FROM proposals WHERE id = ?').get(proposalId);
if (!proposal) {
return res.status(404).json({ error: 'Genehmigung nicht gefunden' });
}
if (approved) {
// Genehmigen
db.prepare(`
UPDATE proposals
SET approved = 1, approved_by = ?, approved_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(req.user.id, proposalId);
logger.info(`Benutzer ${req.user.username} hat Genehmigung ${proposalId} erteilt`);
} else {
// Genehmigung zurueckziehen
db.prepare(`
UPDATE proposals
SET approved = 0, approved_by = NULL, approved_at = NULL
WHERE id = ?
`).run(proposalId);
logger.info(`Benutzer ${req.user.username} hat Genehmigung ${proposalId} zurueckgezogen`);
}
// Aktualisierte Genehmigung zurueckgeben
const updatedProposal = db.prepare(`
SELECT
p.*,
u.display_name as created_by_name,
u.color as created_by_color,
ua.display_name as approved_by_name,
t.title as task_title,
t.id as linked_task_id
FROM proposals p
LEFT JOIN users u ON p.created_by = u.id
LEFT JOIN users ua ON p.approved_by = ua.id
LEFT JOIN tasks t ON p.task_id = t.id
WHERE p.id = ?
`).get(proposalId);
// Benachrichtigungen senden
const io = req.app.get('io');
if (approved) {
// Ersteller benachrichtigen dass genehmigt wurde
if (proposal.created_by !== req.user.id) {
notificationService.create(proposal.created_by, 'approval:granted', {
proposalId: proposalId,
proposalTitle: proposal.title,
projectId: proposal.project_id,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
// Persistente Benachrichtigungen auflösen
notificationService.resolvePersistent(proposalId);
// Aktualisierte Zählung an alle User mit Berechtigung senden
const usersWithPermission = db.prepare(`
SELECT id FROM users
WHERE role = 'user'
AND permissions LIKE '%genehmigung%'
`).all();
usersWithPermission.forEach(user => {
const count = notificationService.getUnreadCount(user.id);
io.to(`user:${user.id}`).emit('notification:count', { count });
});
}
res.json(updatedProposal);
} catch (error) {
logger.error('Fehler beim Genehmigen:', error);
res.status(500).json({ error: 'Fehler beim Genehmigen' });
}
});
/**
* PUT /api/proposals/:id/archive - Genehmigung archivieren/wiederherstellen (nur mit Berechtigung)
*/
router.put('/:id/archive', checkPermission('genehmigung'), (req, res) => {
try {
const proposalId = parseInt(req.params.id);
const { archived } = req.body;
const db = getDb();
// Genehmigung pruefen
const proposal = db.prepare('SELECT * FROM proposals WHERE id = ?').get(proposalId);
if (!proposal) {
return res.status(404).json({ error: 'Genehmigung nicht gefunden' });
}
db.prepare(`
UPDATE proposals
SET archived = ?
WHERE id = ?
`).run(archived ? 1 : 0, proposalId);
logger.info(`Benutzer ${req.user.username} hat Genehmigung ${proposalId} ${archived ? 'archiviert' : 'wiederhergestellt'}`);
// Aktualisierte Genehmigung zurueckgeben
const updatedProposal = db.prepare(`
SELECT
p.*,
u.display_name as created_by_name,
u.color as created_by_color,
ua.display_name as approved_by_name,
t.title as task_title,
t.id as linked_task_id
FROM proposals p
LEFT JOIN users u ON p.created_by = u.id
LEFT JOIN users ua ON p.approved_by = ua.id
LEFT JOIN tasks t ON p.task_id = t.id
WHERE p.id = ?
`).get(proposalId);
res.json(updatedProposal);
} catch (error) {
logger.error('Fehler beim Archivieren:', error);
res.status(500).json({ error: 'Fehler beim Archivieren' });
}
});
/**
* DELETE /api/proposals/:id - Eigene Genehmigung loeschen
*/
router.delete('/:id', (req, res) => {
try {
const proposalId = parseInt(req.params.id);
const db = getDb();
// Genehmigung pruefen
const proposal = db.prepare('SELECT * FROM proposals WHERE id = ?').get(proposalId);
if (!proposal) {
return res.status(404).json({ error: 'Genehmigung nicht gefunden' });
}
// Nur eigene Genehmigungen loeschen (oder mit genehmigung-Berechtigung)
const permissions = req.user.permissions || [];
if (proposal.created_by !== req.user.id && !permissions.includes('genehmigung')) {
return res.status(403).json({ error: 'Nur eigene Genehmigungen koennen geloescht werden' });
}
db.prepare('DELETE FROM proposals WHERE id = ?').run(proposalId);
logger.info(`Benutzer ${req.user.username} hat Genehmigung ${proposalId} geloescht`);
res.json({ success: true });
} catch (error) {
logger.error('Fehler beim Loeschen der Genehmigung:', error);
res.status(500).json({ error: 'Fehler beim Loeschen der Genehmigung' });
}
});
module.exports = router;

310
backend/routes/stats.js Normale Datei
Datei anzeigen

@ -0,0 +1,310 @@
/**
* TASKMATE - Stats Routes
* =======================
* Dashboard-Statistiken
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
/**
* GET /api/stats/dashboard
* Haupt-Dashboard Statistiken
*/
router.get('/dashboard', (req, res) => {
try {
const db = getDb();
const { projectId } = req.query;
let projectFilter = '';
const params = [];
if (projectId) {
projectFilter = ' AND t.project_id = ?';
params.push(projectId);
}
// Gesamtzahlen
const total = db.prepare(`
SELECT COUNT(*) as count FROM tasks t
WHERE t.archived = 0 ${projectFilter}
`).get(...params).count;
// Offene Aufgaben (erste Spalte jedes Projekts)
const open = db.prepare(`
SELECT COUNT(*) as count FROM tasks t
JOIN columns c ON t.column_id = c.id
WHERE t.archived = 0 AND c.position = 0 ${projectFilter}
`).get(...params).count;
// In Arbeit (mittlere Spalten)
const inProgress = db.prepare(`
SELECT COUNT(*) as count FROM tasks t
JOIN columns c ON t.column_id = c.id
WHERE t.archived = 0 AND c.position > 0
AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
${projectFilter}
`).get(...params).count;
// Erledigt (letzte Spalte)
const completed = db.prepare(`
SELECT COUNT(*) as count FROM tasks t
JOIN columns c ON t.column_id = c.id
WHERE t.archived = 0
AND c.position = (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
${projectFilter}
`).get(...params).count;
// Überfällig
const overdue = db.prepare(`
SELECT COUNT(*) as count FROM tasks t
JOIN columns c ON t.column_id = c.id
WHERE t.archived = 0
AND t.due_date < date('now')
AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
${projectFilter}
`).get(...params).count;
// Heute fällig
const dueToday = db.prepare(`
SELECT t.id, t.title, t.priority, t.assigned_to,
u.display_name as assigned_name, u.color as assigned_color
FROM tasks t
LEFT JOIN users u ON t.assigned_to = u.id
JOIN columns c ON t.column_id = c.id
WHERE t.archived = 0
AND t.due_date = date('now')
AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
${projectFilter}
ORDER BY t.priority DESC
LIMIT 10
`).all(...params);
// Bald fällig (nächste 7 Tage)
const dueSoon = db.prepare(`
SELECT COUNT(*) as count FROM tasks t
JOIN columns c ON t.column_id = c.id
WHERE t.archived = 0
AND t.due_date BETWEEN date('now', '+1 day') AND date('now', '+7 days')
AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
${projectFilter}
`).get(...params).count;
res.json({
total,
open,
inProgress,
completed,
overdue,
dueSoon,
dueToday: dueToday.map(t => ({
id: t.id,
title: t.title,
priority: t.priority,
assignedTo: t.assigned_to,
assignedName: t.assigned_name,
assignedColor: t.assigned_color
}))
});
} catch (error) {
logger.error('Fehler bei Dashboard-Stats:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/stats/completed-per-week
* Erledigte Aufgaben pro Woche
*/
router.get('/completed-per-week', (req, res) => {
try {
const db = getDb();
const { projectId, weeks = 8 } = req.query;
let projectFilter = '';
const params = [parseInt(weeks)];
if (projectId) {
projectFilter = ' AND h.task_id IN (SELECT id FROM tasks WHERE project_id = ?)';
params.push(projectId);
}
// Erledigte Aufgaben pro Kalenderwoche
const stats = db.prepare(`
SELECT
strftime('%Y-%W', h.timestamp) as week,
COUNT(DISTINCT h.task_id) as count
FROM history h
WHERE h.action = 'moved'
AND h.new_value IN (
SELECT name FROM columns c
WHERE c.position = (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
)
AND h.timestamp >= date('now', '-' || ? || ' weeks')
${projectFilter}
GROUP BY week
ORDER BY week DESC
`).all(...params);
// Letzten X Wochen mit 0 auffüllen
const result = [];
const now = new Date();
for (let i = 0; i < parseInt(weeks); i++) {
const date = new Date(now);
date.setDate(date.getDate() - (i * 7));
const year = date.getFullYear();
const week = getWeekNumber(date);
const weekKey = `${year}-${week.toString().padStart(2, '0')}`;
const found = stats.find(s => s.week === weekKey);
result.unshift({
week: weekKey,
label: `KW${week}`,
count: found ? found.count : 0
});
}
res.json(result);
} catch (error) {
logger.error('Fehler bei Completed-per-Week Stats:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/stats/time-per-project
* Geschätzte Zeit pro Projekt
*/
router.get('/time-per-project', (req, res) => {
try {
const db = getDb();
const stats = db.prepare(`
SELECT
p.id,
p.name,
COALESCE(SUM(t.time_estimate_min), 0) as total_minutes,
COUNT(t.id) as task_count
FROM projects p
LEFT JOIN tasks t ON p.id = t.project_id AND t.archived = 0
WHERE p.archived = 0
GROUP BY p.id
ORDER BY total_minutes DESC
`).all();
res.json(stats.map(s => ({
id: s.id,
name: s.name,
totalMinutes: s.total_minutes,
totalHours: Math.round(s.total_minutes / 60 * 10) / 10,
taskCount: s.task_count
})));
} catch (error) {
logger.error('Fehler bei Time-per-Project Stats:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/stats/user-activity
* Aktivität pro Benutzer
*/
router.get('/user-activity', (req, res) => {
try {
const db = getDb();
const { days = 30 } = req.query;
const stats = db.prepare(`
SELECT
u.id,
u.display_name,
u.color,
COUNT(DISTINCT CASE WHEN h.action = 'created' THEN h.task_id END) as tasks_created,
COUNT(DISTINCT CASE WHEN h.action = 'moved' THEN h.task_id END) as tasks_moved,
COUNT(DISTINCT CASE WHEN h.action = 'commented' THEN h.id END) as comments,
COUNT(h.id) as total_actions
FROM users u
LEFT JOIN history h ON u.id = h.user_id AND h.timestamp >= date('now', '-' || ? || ' days')
GROUP BY u.id
ORDER BY total_actions DESC
`).all(parseInt(days));
res.json(stats.map(s => ({
id: s.id,
displayName: s.display_name,
color: s.color,
tasksCreated: s.tasks_created,
tasksMoved: s.tasks_moved,
comments: s.comments,
totalActions: s.total_actions
})));
} catch (error) {
logger.error('Fehler bei User-Activity Stats:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/stats/calendar
* Aufgaben nach Datum (für Kalender)
*/
router.get('/calendar', (req, res) => {
try {
const db = getDb();
const { projectId, month, year } = req.query;
const currentYear = year || new Date().getFullYear();
const currentMonth = month || (new Date().getMonth() + 1);
// Start und Ende des Monats
const startDate = `${currentYear}-${currentMonth.toString().padStart(2, '0')}-01`;
const endDate = `${currentYear}-${currentMonth.toString().padStart(2, '0')}-31`;
let query = `
SELECT
t.due_date,
COUNT(*) as count,
SUM(CASE WHEN t.due_date < date('now') AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id) THEN 1 ELSE 0 END) as overdue_count
FROM tasks t
JOIN columns c ON t.column_id = c.id
WHERE t.archived = 0
AND t.due_date BETWEEN ? AND ?
`;
const params = [startDate, endDate];
if (projectId) {
query += ' AND t.project_id = ?';
params.push(projectId);
}
query += ' GROUP BY t.due_date ORDER BY t.due_date';
const stats = db.prepare(query).all(...params);
res.json(stats.map(s => ({
date: s.due_date,
count: s.count,
overdueCount: s.overdue_count
})));
} catch (error) {
logger.error('Fehler bei Calendar Stats:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* Hilfsfunktion: Kalenderwoche berechnen
*/
function getWeekNumber(date) {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
}
module.exports = router;

279
backend/routes/subtasks.js Normale Datei
Datei anzeigen

@ -0,0 +1,279 @@
/**
* TASKMATE - Subtask Routes
* =========================
* CRUD für Unteraufgaben/Checkliste
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators } = require('../middleware/validation');
/**
* GET /api/subtasks/:taskId
* Alle Unteraufgaben einer Aufgabe
*/
router.get('/:taskId', (req, res) => {
try {
const db = getDb();
const subtasks = db.prepare(`
SELECT * FROM subtasks WHERE task_id = ? ORDER BY position
`).all(req.params.taskId);
res.json(subtasks.map(s => ({
id: s.id,
taskId: s.task_id,
title: s.title,
completed: !!s.completed,
position: s.position
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Subtasks:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/subtasks
* Neue Unteraufgabe erstellen
*/
router.post('/', (req, res) => {
try {
const { taskId, title } = req.body;
// Validierung
const titleError = validators.required(title, 'Titel') ||
validators.maxLength(title, 200, 'Titel');
if (titleError) {
return res.status(400).json({ error: titleError });
}
const db = getDb();
// Task prüfen
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!task) {
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;
// Subtask erstellen
const result = db.prepare(`
INSERT INTO subtasks (task_id, title, position)
VALUES (?, ?, ?)
`).run(taskId, title, maxPos + 1);
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(result.lastInsertRowid);
logger.info(`Subtask erstellt: ${title} in Task ${taskId}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('subtask:created', {
taskId,
subtask: {
id: subtask.id,
taskId: subtask.task_id,
title: subtask.title,
completed: false,
position: subtask.position
}
});
res.status(201).json({
id: subtask.id,
taskId: subtask.task_id,
title: subtask.title,
completed: false,
position: subtask.position
});
} catch (error) {
logger.error('Fehler beim Erstellen des Subtasks:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/subtasks/:id
* Unteraufgabe aktualisieren
*/
router.put('/:id', (req, res) => {
try {
const subtaskId = req.params.id;
const { title, completed } = req.body;
const db = getDb();
const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(subtaskId);
if (!subtask) {
return res.status(404).json({ error: 'Unteraufgabe nicht gefunden' });
}
// Validierung
if (title) {
const titleError = validators.maxLength(title, 200, 'Titel');
if (titleError) {
return res.status(400).json({ error: titleError });
}
}
db.prepare(`
UPDATE subtasks SET
title = COALESCE(?, title),
completed = COALESCE(?, completed)
WHERE id = ?
`).run(title || null, completed !== undefined ? (completed ? 1 : 0) : null, subtaskId);
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(subtask.task_id);
const updated = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(subtaskId);
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(subtask.task_id);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('subtask:updated', {
taskId: subtask.task_id,
subtask: {
id: updated.id,
taskId: updated.task_id,
title: updated.title,
completed: !!updated.completed,
position: updated.position
}
});
res.json({
id: updated.id,
taskId: updated.task_id,
title: updated.title,
completed: !!updated.completed,
position: updated.position
});
} catch (error) {
logger.error('Fehler beim Aktualisieren des Subtasks:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/subtasks/:id/position
* Unteraufgabe-Position ändern
*/
router.put('/:id/position', (req, res) => {
try {
const subtaskId = req.params.id;
const { newPosition } = req.body;
const db = getDb();
const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(subtaskId);
if (!subtask) {
return res.status(404).json({ error: 'Unteraufgabe nicht gefunden' });
}
const oldPosition = subtask.position;
const taskId = subtask.task_id;
if (newPosition > oldPosition) {
db.prepare(`
UPDATE subtasks SET position = position - 1
WHERE task_id = ? AND position > ? AND position <= ?
`).run(taskId, oldPosition, newPosition);
} else if (newPosition < oldPosition) {
db.prepare(`
UPDATE subtasks SET position = position + 1
WHERE task_id = ? AND position >= ? AND position < ?
`).run(taskId, newPosition, oldPosition);
}
db.prepare('UPDATE subtasks SET position = ? WHERE id = ?').run(newPosition, subtaskId);
const subtasks = db.prepare(
'SELECT * FROM subtasks WHERE task_id = ? ORDER BY position'
).all(taskId);
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(taskId);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('subtasks:reordered', {
taskId,
subtasks: subtasks.map(s => ({
id: s.id,
title: s.title,
completed: !!s.completed,
position: s.position
}))
});
res.json({
subtasks: subtasks.map(s => ({
id: s.id,
taskId: s.task_id,
title: s.title,
completed: !!s.completed,
position: s.position
}))
});
} catch (error) {
logger.error('Fehler beim Verschieben des Subtasks:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/subtasks/:id
* Unteraufgabe löschen
*/
router.delete('/:id', (req, res) => {
try {
const subtaskId = req.params.id;
const db = getDb();
const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(subtaskId);
if (!subtask) {
return res.status(404).json({ error: 'Unteraufgabe nicht gefunden' });
}
const taskId = subtask.task_id;
db.prepare('DELETE FROM subtasks WHERE id = ?').run(subtaskId);
// Positionen neu nummerieren
const remaining = db.prepare(
'SELECT id FROM subtasks WHERE task_id = ? ORDER BY position'
).all(taskId);
remaining.forEach((s, idx) => {
db.prepare('UPDATE subtasks SET position = ? WHERE id = ?').run(idx, s.id);
});
// Task updated_at aktualisieren
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(taskId);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('subtask:deleted', {
taskId,
subtaskId
});
res.json({ message: 'Unteraufgabe gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen des Subtasks:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

899
backend/routes/tasks.js Normale Datei
Datei anzeigen

@ -0,0 +1,899 @@
/**
* TASKMATE - Task Routes
* ======================
* CRUD für Aufgaben
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators } = require('../middleware/validation');
const notificationService = require('../services/notificationService');
/**
* Hilfsfunktion: Historie-Eintrag erstellen
*/
function addHistory(db, taskId, userId, action, fieldChanged = null, oldValue = null, newValue = null) {
db.prepare(`
INSERT INTO history (task_id, user_id, action, field_changed, old_value, new_value)
VALUES (?, ?, ?, ?, ?, ?)
`).run(taskId, userId, action, fieldChanged, oldValue, newValue);
}
/**
* Hilfsfunktion: Vollständige Task-Daten laden
*/
function getFullTask(db, taskId) {
const task = db.prepare(`
SELECT t.*,
c.username as creator_name
FROM tasks t
LEFT JOIN users c ON t.created_by = c.id
WHERE t.id = ?
`).get(taskId);
if (!task) return null;
// Zugewiesene Mitarbeiter laden (Mehrfachzuweisung)
const assignees = db.prepare(`
SELECT u.id, u.username, u.display_name, u.color
FROM task_assignees ta
JOIN users u ON ta.user_id = u.id
WHERE ta.task_id = ?
ORDER BY u.username
`).all(taskId);
// Labels laden
const labels = db.prepare(`
SELECT l.* FROM labels l
JOIN task_labels tl ON l.id = tl.label_id
WHERE tl.task_id = ?
`).all(taskId);
// Subtasks laden
const subtasks = db.prepare(`
SELECT * FROM subtasks WHERE task_id = ? ORDER BY position
`).all(taskId);
// Anhänge zählen
const attachmentCount = db.prepare(
'SELECT COUNT(*) as count FROM attachments WHERE task_id = ?'
).get(taskId).count;
// Links zählen
const linkCount = db.prepare(
'SELECT COUNT(*) as count FROM links WHERE task_id = ?'
).get(taskId).count;
// Kommentare zählen
const commentCount = db.prepare(
'SELECT COUNT(*) as count FROM comments WHERE task_id = ?'
).get(taskId).count;
// Verknüpfte Genehmigungen laden
const proposals = db.prepare(`
SELECT p.id, p.title, p.approved, p.approved_by,
u.display_name as approved_by_name
FROM proposals p
LEFT JOIN users u ON p.approved_by = u.id
WHERE p.task_id = ? AND p.archived = 0
ORDER BY p.created_at DESC
`).all(taskId);
return {
id: task.id,
projectId: task.project_id,
columnId: task.column_id,
title: task.title,
description: task.description,
priority: task.priority,
startDate: task.start_date,
dueDate: task.due_date,
// Neues Format: Array von Mitarbeitern
assignees: assignees.map(a => ({
id: a.id,
username: a.username,
display_name: a.display_name,
color: a.color
})),
// Rückwärtskompatibilität: assignedTo als erster Mitarbeiter (falls vorhanden)
assignedTo: assignees.length > 0 ? assignees[0].id : null,
assignedName: assignees.length > 0 ? assignees[0].username : null,
assignedColor: assignees.length > 0 ? assignees[0].color : null,
timeEstimateMin: task.time_estimate_min,
dependsOn: task.depends_on,
position: task.position,
archived: !!task.archived,
createdAt: task.created_at,
createdBy: task.created_by,
creatorName: task.creator_name,
updatedAt: task.updated_at,
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color })),
subtasks: subtasks.map(s => ({
id: s.id,
title: s.title,
completed: !!s.completed,
position: s.position
})),
subtaskProgress: {
total: subtasks.length,
completed: subtasks.filter(s => s.completed).length
},
attachmentCount,
linkCount,
commentCount,
proposals: proposals.map(p => ({
id: p.id,
title: p.title,
approved: !!p.approved,
approvedByName: p.approved_by_name
}))
};
}
/**
* GET /api/tasks/all
* Alle aktiven Aufgaben (nicht archiviert) fuer Auswahl in Vorschlaegen
*/
router.get('/all', (req, res) => {
try {
const db = getDb();
const tasks = db.prepare(`
SELECT t.id, t.title, t.project_id, p.name as project_name
FROM tasks t
LEFT JOIN projects p ON t.project_id = p.id
WHERE t.archived = 0
ORDER BY p.name, t.title
`).all();
res.json(tasks);
} catch (error) {
logger.error('Fehler beim Abrufen aller Aufgaben:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/tasks/project/:projectId
* Alle Aufgaben eines Projekts
*/
router.get('/project/:projectId', (req, res) => {
try {
const db = getDb();
const projectId = req.params.projectId;
const includeArchived = req.query.archived === 'true';
let query = `
SELECT t.id FROM tasks t
WHERE t.project_id = ?
`;
if (!includeArchived) {
query += ' AND t.archived = 0';
}
query += ' ORDER BY t.column_id, t.position';
const taskIds = db.prepare(query).all(projectId);
const tasks = taskIds.map(t => getFullTask(db, t.id));
res.json(tasks);
} catch (error) {
logger.error('Fehler beim Abrufen der Aufgaben:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/tasks/search
* Aufgaben suchen - durchsucht auch Subtasks, Links, Anhänge und Kommentare
* WICHTIG: Diese Route MUSS vor /:id definiert werden!
*/
router.get('/search', (req, res) => {
try {
const { q, projectId, assignedTo, priority, dueBefore, dueAfter, labels, archived } = req.query;
const db = getDb();
const params = [];
// Basis-Query mit LEFT JOINs für tiefe Suche
let query = `
SELECT DISTINCT t.id FROM tasks t
LEFT JOIN subtasks s ON t.id = s.task_id
LEFT JOIN links l ON t.id = l.task_id
LEFT JOIN attachments a ON t.id = a.task_id
LEFT JOIN comments c ON t.id = c.task_id
WHERE 1=1
`;
if (projectId) {
query += ' AND t.project_id = ?';
params.push(projectId);
}
// Erweiterte Textsuche: Titel, Beschreibung, Subtasks, Links, Anhänge, Kommentare
if (q) {
const searchTerm = `%${q}%`;
query += ` AND (
t.title LIKE ?
OR t.description LIKE ?
OR s.title LIKE ?
OR l.url LIKE ?
OR l.title LIKE ?
OR a.original_name LIKE ?
OR c.content LIKE ?
)`;
params.push(searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm);
}
if (assignedTo) {
query += ' AND t.assigned_to = ?';
params.push(assignedTo);
}
if (priority) {
query += ' AND t.priority = ?';
params.push(priority);
}
if (dueBefore) {
query += ' AND t.due_date <= ?';
params.push(dueBefore);
}
if (dueAfter) {
query += ' AND t.due_date >= ?';
params.push(dueAfter);
}
if (archived !== 'true') {
query += ' AND t.archived = 0';
}
if (labels) {
const labelIds = labels.split(',').map(id => parseInt(id)).filter(id => !isNaN(id));
if (labelIds.length > 0) {
query += ` AND t.id IN (
SELECT task_id FROM task_labels WHERE label_id IN (${labelIds.map(() => '?').join(',')})
)`;
params.push(...labelIds);
}
}
query += ' ORDER BY t.due_date ASC, t.priority DESC, t.updated_at DESC LIMIT 100';
const taskIds = db.prepare(query).all(...params);
const tasks = taskIds.map(t => getFullTask(db, t.id));
logger.info(`Suche nach "${q}" in Projekt ${projectId}: ${tasks.length} Treffer`);
res.json(tasks);
} catch (error) {
logger.error('Fehler bei der Suche:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* GET /api/tasks/:id
* Einzelne Aufgabe mit allen Details
*/
router.get('/:id', (req, res) => {
try {
const db = getDb();
const task = getFullTask(db, req.params.id);
if (!task) {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
// Historie laden
const history = db.prepare(`
SELECT h.*, u.display_name, u.color
FROM history h
JOIN users u ON h.user_id = u.id
WHERE h.task_id = ?
ORDER BY h.timestamp DESC
LIMIT 50
`).all(req.params.id);
task.history = history.map(h => ({
id: h.id,
action: h.action,
fieldChanged: h.field_changed,
oldValue: h.old_value,
newValue: h.new_value,
timestamp: h.timestamp,
userName: h.display_name,
userColor: h.color
}));
res.json(task);
} catch (error) {
logger.error('Fehler beim Abrufen der Aufgabe:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/tasks
* Neue Aufgabe erstellen
*/
router.post('/', (req, res) => {
try {
const {
projectId, columnId, title, description, priority,
startDate, dueDate, assignees, assignedTo, timeEstimateMin, dependsOn, labels
} = req.body;
// Validierung
const errors = [];
errors.push(validators.required(projectId, 'Projekt-ID'));
errors.push(validators.required(columnId, 'Spalten-ID'));
errors.push(validators.required(title, 'Titel'));
errors.push(validators.maxLength(title, 200, 'Titel'));
if (priority) errors.push(validators.enum(priority, ['low', 'medium', 'high'], 'Priorität'));
if (startDate) errors.push(validators.date(startDate, 'Startdatum'));
if (dueDate) errors.push(validators.date(dueDate, 'Fälligkeitsdatum'));
if (timeEstimateMin) errors.push(validators.positiveInteger(timeEstimateMin, 'Zeitschätzung'));
const firstError = errors.find(e => e !== null);
if (firstError) {
return res.status(400).json({ error: firstError });
}
const db = getDb();
// Höchste Position in der Spalte ermitteln
const maxPos = db.prepare(
'SELECT COALESCE(MAX(position), -1) as max FROM tasks WHERE column_id = ?'
).get(columnId).max;
// Aufgabe erstellen (ohne assigned_to, wird über task_assignees gemacht)
const result = db.prepare(`
INSERT INTO tasks (
project_id, column_id, title, description, priority,
start_date, due_date, time_estimate_min, depends_on, position, created_by
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
projectId, columnId, title, description || null, priority || 'medium',
startDate || null, dueDate || null, timeEstimateMin || null,
dependsOn || null, maxPos + 1, req.user.id
);
const taskId = result.lastInsertRowid;
// Mitarbeiter zuweisen (Mehrfachzuweisung)
const assigneeIds = assignees && Array.isArray(assignees) ? assignees :
(assignedTo ? [assignedTo] : []);
if (assigneeIds.length > 0) {
const insertAssignee = db.prepare('INSERT INTO task_assignees (task_id, user_id) VALUES (?, ?)');
assigneeIds.forEach(userId => {
try {
insertAssignee.run(taskId, userId);
} catch (e) {
// User existiert nicht oder bereits zugewiesen
}
});
}
// Labels zuweisen
if (labels && Array.isArray(labels)) {
const insertLabel = db.prepare('INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)');
labels.forEach(labelId => {
try {
insertLabel.run(taskId, labelId);
} catch (e) {
// Label existiert nicht, ignorieren
}
});
}
// Historie
addHistory(db, taskId, req.user.id, 'created');
const task = getFullTask(db, taskId);
logger.info(`Aufgabe erstellt: ${title} (ID: ${taskId}) von ${req.user.username}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${projectId}`).emit('task:created', task);
// Benachrichtigungen an zugewiesene Mitarbeiter (außer Ersteller)
if (assigneeIds.length > 0) {
assigneeIds.forEach(assigneeId => {
if (assigneeId !== req.user.id) {
notificationService.create(assigneeId, 'task:assigned', {
taskId: taskId,
taskTitle: title,
projectId: projectId,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
});
}
res.status(201).json(task);
} catch (error) {
logger.error('Fehler beim Erstellen der Aufgabe:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/tasks/:id
* Aufgabe aktualisieren
*/
router.put('/:id', (req, res) => {
try {
const taskId = req.params.id;
const {
title, description, priority, columnId, startDate, dueDate, assignees, assignedTo,
timeEstimateMin, dependsOn, labels
} = req.body;
const db = getDb();
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!existing) {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
// Validierung
if (title) {
const titleError = validators.maxLength(title, 200, 'Titel');
if (titleError) return res.status(400).json({ error: titleError });
}
if (priority) {
const prioError = validators.enum(priority, ['low', 'medium', 'high'], 'Priorität');
if (prioError) return res.status(400).json({ error: prioError });
}
if (startDate) {
const startDateError = validators.date(startDate, 'Startdatum');
if (startDateError) return res.status(400).json({ error: startDateError });
}
if (dueDate) {
const dateError = validators.date(dueDate, 'Fälligkeitsdatum');
if (dateError) return res.status(400).json({ error: dateError });
}
// Änderungen tracken für Historie
const changes = [];
if (title !== undefined && title !== existing.title) {
changes.push({ field: 'title', old: existing.title, new: title });
}
if (description !== undefined && description !== existing.description) {
changes.push({ field: 'description', old: existing.description, new: description });
}
if (priority !== undefined && priority !== existing.priority) {
changes.push({ field: 'priority', old: existing.priority, new: priority });
}
if (columnId !== undefined && columnId !== existing.column_id) {
const oldColumn = db.prepare('SELECT name FROM columns WHERE id = ?').get(existing.column_id);
const newColumn = db.prepare('SELECT name FROM columns WHERE id = ?').get(columnId);
changes.push({ field: 'column', old: oldColumn?.name, new: newColumn?.name });
}
if (startDate !== undefined && startDate !== existing.start_date) {
changes.push({ field: 'start_date', old: existing.start_date, new: startDate });
}
if (dueDate !== undefined && dueDate !== existing.due_date) {
changes.push({ field: 'due_date', old: existing.due_date, new: dueDate });
}
if (timeEstimateMin !== undefined && timeEstimateMin !== existing.time_estimate_min) {
changes.push({ field: 'time_estimate', old: String(existing.time_estimate_min), new: String(timeEstimateMin) });
}
if (dependsOn !== undefined && dependsOn !== existing.depends_on) {
changes.push({ field: 'depends_on', old: String(existing.depends_on), new: String(dependsOn) });
}
// Aufgabe aktualisieren (ohne assigned_to)
db.prepare(`
UPDATE tasks SET
title = COALESCE(?, title),
description = ?,
priority = COALESCE(?, priority),
column_id = ?,
start_date = ?,
due_date = ?,
time_estimate_min = ?,
depends_on = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(
title || null,
description !== undefined ? description : existing.description,
priority || null,
columnId !== undefined ? columnId : existing.column_id,
startDate !== undefined ? startDate : existing.start_date,
dueDate !== undefined ? dueDate : existing.due_date,
timeEstimateMin !== undefined ? timeEstimateMin : existing.time_estimate_min,
dependsOn !== undefined ? dependsOn : existing.depends_on,
taskId
);
// Mitarbeiter aktualisieren (Mehrfachzuweisung)
if (assignees !== undefined && Array.isArray(assignees)) {
// Alte Zuweisungen entfernen
db.prepare('DELETE FROM task_assignees WHERE task_id = ?').run(taskId);
// Neue Zuweisungen hinzufügen
const insertAssignee = db.prepare('INSERT INTO task_assignees (task_id, user_id) VALUES (?, ?)');
assignees.forEach(userId => {
try {
insertAssignee.run(taskId, userId);
} catch (e) {
// User existiert nicht oder bereits zugewiesen
}
});
changes.push({ field: 'assignees', old: 'changed', new: 'changed' });
} else if (assignedTo !== undefined) {
// Rückwärtskompatibilität: einzelne Zuweisung
db.prepare('DELETE FROM task_assignees WHERE task_id = ?').run(taskId);
if (assignedTo) {
try {
db.prepare('INSERT INTO task_assignees (task_id, user_id) VALUES (?, ?)').run(taskId, assignedTo);
} catch (e) {
// Ignorieren
}
}
changes.push({ field: 'assignees', old: 'changed', new: 'changed' });
}
// Labels aktualisieren
if (labels !== undefined && Array.isArray(labels)) {
// Alte Labels entfernen
db.prepare('DELETE FROM task_labels WHERE task_id = ?').run(taskId);
// Neue Labels zuweisen
const insertLabel = db.prepare('INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)');
labels.forEach(labelId => {
try {
insertLabel.run(taskId, labelId);
} catch (e) {
// Label existiert nicht
}
});
changes.push({ field: 'labels', old: 'changed', new: 'changed' });
}
// Historie-Einträge
changes.forEach(change => {
addHistory(db, taskId, req.user.id, 'updated', change.field, change.old, change.new);
});
const task = getFullTask(db, taskId);
logger.info(`Aufgabe aktualisiert: ${task.title} (ID: ${taskId})`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${existing.project_id}`).emit('task:updated', task);
// Benachrichtigungen für Änderungen senden
const taskTitle = title || existing.title;
// Zuweisung geändert - neue Mitarbeiter benachrichtigen
if (assignees !== undefined && Array.isArray(assignees)) {
// Alte Mitarbeiter ermitteln
const oldAssignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
const oldAssigneeIds = oldAssignees.map(a => a.user_id);
// Neue Mitarbeiter (die vorher nicht zugewiesen waren)
const newAssigneeIds = assignees.filter(id => !oldAssigneeIds.includes(id));
newAssigneeIds.forEach(assigneeId => {
if (assigneeId !== req.user.id) {
notificationService.create(assigneeId, 'task:assigned', {
taskId: parseInt(taskId),
taskTitle: taskTitle,
projectId: existing.project_id,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
});
// Entfernte Mitarbeiter
const removedAssigneeIds = oldAssigneeIds.filter(id => !assignees.includes(id));
removedAssigneeIds.forEach(assigneeId => {
if (assigneeId !== req.user.id) {
notificationService.create(assigneeId, 'task:unassigned', {
taskId: parseInt(taskId),
taskTitle: taskTitle,
projectId: existing.project_id,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
});
}
// Priorität auf hoch gesetzt
if (priority === 'high' && existing.priority !== 'high') {
// Alle Assignees benachrichtigen
const currentAssignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
currentAssignees.forEach(a => {
if (a.user_id !== req.user.id) {
notificationService.create(a.user_id, 'task:priority_up', {
taskId: parseInt(taskId),
taskTitle: taskTitle,
projectId: existing.project_id,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
});
}
// Fälligkeitsdatum geändert
if (dueDate !== undefined && dueDate !== existing.due_date) {
const currentAssignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
currentAssignees.forEach(a => {
if (a.user_id !== req.user.id) {
notificationService.create(a.user_id, 'task:due_changed', {
taskId: parseInt(taskId),
taskTitle: taskTitle,
projectId: existing.project_id,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
});
}
res.json(task);
} catch (error) {
logger.error('Fehler beim Aktualisieren der Aufgabe:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/tasks/:id/move
* Aufgabe verschieben (Spalte/Position)
*/
router.put('/:id/move', (req, res) => {
try {
const taskId = req.params.id;
const { columnId, position } = req.body;
const db = getDb();
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!task) {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
const oldColumnId = task.column_id;
const oldPosition = task.position;
const newColumnId = columnId || oldColumnId;
const newPosition = position !== undefined ? position : oldPosition;
// Spaltenname für Historie
const oldColumn = db.prepare('SELECT name FROM columns WHERE id = ?').get(oldColumnId);
const newColumn = db.prepare('SELECT name FROM columns WHERE id = ?').get(newColumnId);
if (oldColumnId !== newColumnId) {
// In andere Spalte verschoben
// Positionen in alter Spalte anpassen
db.prepare(`
UPDATE tasks SET position = position - 1
WHERE column_id = ? AND position > ?
`).run(oldColumnId, oldPosition);
// Positionen in neuer Spalte anpassen
db.prepare(`
UPDATE tasks SET position = position + 1
WHERE column_id = ? AND position >= ?
`).run(newColumnId, newPosition);
// Task verschieben
db.prepare(`
UPDATE tasks SET column_id = ?, position = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(newColumnId, newPosition, taskId);
addHistory(db, taskId, req.user.id, 'moved', 'column', oldColumn?.name, newColumn?.name);
} else if (oldPosition !== newPosition) {
// Innerhalb der Spalte verschoben
if (newPosition > oldPosition) {
db.prepare(`
UPDATE tasks SET position = position - 1
WHERE column_id = ? AND position > ? AND position <= ?
`).run(newColumnId, oldPosition, newPosition);
} else {
db.prepare(`
UPDATE tasks SET position = position + 1
WHERE column_id = ? AND position >= ? AND position < ?
`).run(newColumnId, newPosition, oldPosition);
}
db.prepare(`
UPDATE tasks SET position = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`).run(newPosition, taskId);
}
const updatedTask = getFullTask(db, taskId);
logger.info(`Aufgabe verschoben: ${task.title} -> ${newColumn?.name || 'Position ' + newPosition}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('task:moved', {
task: updatedTask,
oldColumnId,
newColumnId,
oldPosition,
newPosition
});
// Benachrichtigung wenn in Erledigt-Spalte verschoben
if (oldColumnId !== newColumnId) {
const newColumnFull = db.prepare('SELECT filter_category FROM columns WHERE id = ?').get(newColumnId);
const oldColumnFull = db.prepare('SELECT filter_category FROM columns WHERE id = ?').get(oldColumnId);
// Prüfen ob in Erledigt-Spalte verschoben (und vorher nicht dort war)
if (newColumnFull?.filter_category === 'completed' && oldColumnFull?.filter_category !== 'completed') {
// Alle Assignees benachrichtigen
const assignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
assignees.forEach(a => {
if (a.user_id !== req.user.id) {
notificationService.create(a.user_id, 'task:completed', {
taskId: parseInt(taskId),
taskTitle: task.title,
projectId: task.project_id,
actorId: req.user.id,
actorName: req.user.display_name || req.user.username
}, io);
}
});
}
}
res.json(updatedTask);
} catch (error) {
logger.error('Fehler beim Verschieben der Aufgabe:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/tasks/:id/duplicate
* Aufgabe duplizieren
*/
router.post('/:id/duplicate', (req, res) => {
try {
const taskId = req.params.id;
const db = getDb();
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!task) {
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 tasks WHERE column_id = ?'
).get(task.column_id).max;
// Aufgabe duplizieren
const result = db.prepare(`
INSERT INTO tasks (
project_id, column_id, title, description, priority,
due_date, assigned_to, time_estimate_min, position, created_by
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
task.project_id, task.column_id, task.title + ' (Kopie)', task.description,
task.priority, task.due_date, task.assigned_to, task.time_estimate_min,
maxPos + 1, req.user.id
);
const newTaskId = result.lastInsertRowid;
// Labels kopieren
const taskLabels = db.prepare('SELECT label_id FROM task_labels WHERE task_id = ?').all(taskId);
const insertLabel = db.prepare('INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)');
taskLabels.forEach(tl => insertLabel.run(newTaskId, tl.label_id));
// Subtasks kopieren
const subtasks = db.prepare('SELECT * FROM subtasks WHERE task_id = ? ORDER BY position').all(taskId);
const insertSubtask = db.prepare('INSERT INTO subtasks (task_id, title, position) VALUES (?, ?, ?)');
subtasks.forEach((st, idx) => insertSubtask.run(newTaskId, st.title, idx));
addHistory(db, newTaskId, req.user.id, 'created', null, null, `Kopie von #${taskId}`);
const newTask = getFullTask(db, newTaskId);
logger.info(`Aufgabe dupliziert: ${task.title} -> ${newTask.title}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('task:created', newTask);
res.status(201).json(newTask);
} catch (error) {
logger.error('Fehler beim Duplizieren:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/tasks/:id/archive
* Aufgabe archivieren
*/
router.put('/:id/archive', (req, res) => {
try {
const taskId = req.params.id;
const { archived } = req.body;
const db = getDb();
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!task) {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
db.prepare('UPDATE tasks SET archived = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
.run(archived ? 1 : 0, taskId);
addHistory(db, taskId, req.user.id, archived ? 'archived' : 'restored');
logger.info(`Aufgabe ${archived ? 'archiviert' : 'wiederhergestellt'}: ${task.title}`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('task:archived', { id: taskId, archived: !!archived });
res.json({ message: archived ? 'Aufgabe archiviert' : 'Aufgabe wiederhergestellt' });
} catch (error) {
logger.error('Fehler beim Archivieren:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/tasks/:id
* Aufgabe löschen
*/
router.delete('/:id', (req, res) => {
try {
const taskId = req.params.id;
const db = getDb();
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
if (!task) {
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
}
db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
// Positionen neu nummerieren
const remainingTasks = db.prepare(
'SELECT id FROM tasks WHERE column_id = ? ORDER BY position'
).all(task.column_id);
remainingTasks.forEach((t, idx) => {
db.prepare('UPDATE tasks SET position = ? WHERE id = ?').run(idx, t.id);
});
logger.info(`Aufgabe gelöscht: ${task.title} (ID: ${taskId})`);
// WebSocket
const io = req.app.get('io');
io.to(`project:${task.project_id}`).emit('task:deleted', {
id: taskId,
columnId: task.column_id
});
res.json({ message: 'Aufgabe gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

336
backend/routes/templates.js Normale Datei
Datei anzeigen

@ -0,0 +1,336 @@
/**
* TASKMATE - Template Routes
* ==========================
* CRUD für Aufgaben-Vorlagen
*/
const express = require('express');
const router = express.Router();
const { getDb } = require('../database');
const logger = require('../utils/logger');
const { validators } = require('../middleware/validation');
/**
* GET /api/templates/:projectId
* Alle Vorlagen eines Projekts
*/
router.get('/:projectId', (req, res) => {
try {
const db = getDb();
const templates = db.prepare(`
SELECT * FROM task_templates WHERE project_id = ? ORDER BY name
`).all(req.params.projectId);
res.json(templates.map(t => ({
id: t.id,
projectId: t.project_id,
name: t.name,
titleTemplate: t.title_template,
description: t.description,
priority: t.priority,
labels: t.labels ? JSON.parse(t.labels) : [],
subtasks: t.subtasks ? JSON.parse(t.subtasks) : [],
timeEstimateMin: t.time_estimate_min
})));
} catch (error) {
logger.error('Fehler beim Abrufen der Vorlagen:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/templates
* Neue Vorlage erstellen
*/
router.post('/', (req, res) => {
try {
const {
projectId, name, titleTemplate, description,
priority, labels, subtasks, timeEstimateMin
} = req.body;
// Validierung
const errors = [];
errors.push(validators.required(projectId, 'Projekt-ID'));
errors.push(validators.required(name, 'Name'));
errors.push(validators.maxLength(name, 50, 'Name'));
if (titleTemplate) errors.push(validators.maxLength(titleTemplate, 200, 'Titel-Vorlage'));
if (priority) errors.push(validators.enum(priority, ['low', 'medium', 'high'], 'Priorität'));
const firstError = errors.find(e => e !== null);
if (firstError) {
return res.status(400).json({ error: firstError });
}
const db = getDb();
const result = db.prepare(`
INSERT INTO task_templates (
project_id, name, title_template, description,
priority, labels, subtasks, time_estimate_min
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
projectId,
name,
titleTemplate || null,
description || null,
priority || 'medium',
labels ? JSON.stringify(labels) : null,
subtasks ? JSON.stringify(subtasks) : null,
timeEstimateMin || null
);
const template = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(result.lastInsertRowid);
logger.info(`Vorlage erstellt: ${name} in Projekt ${projectId}`);
res.status(201).json({
id: template.id,
projectId: template.project_id,
name: template.name,
titleTemplate: template.title_template,
description: template.description,
priority: template.priority,
labels: template.labels ? JSON.parse(template.labels) : [],
subtasks: template.subtasks ? JSON.parse(template.subtasks) : [],
timeEstimateMin: template.time_estimate_min
});
} catch (error) {
logger.error('Fehler beim Erstellen der Vorlage:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* PUT /api/templates/:id
* Vorlage aktualisieren
*/
router.put('/:id', (req, res) => {
try {
const templateId = req.params.id;
const {
name, titleTemplate, description,
priority, labels, subtasks, timeEstimateMin
} = req.body;
const db = getDb();
const existing = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(templateId);
if (!existing) {
return res.status(404).json({ error: 'Vorlage nicht gefunden' });
}
// Validierung
if (name) {
const nameError = validators.maxLength(name, 50, 'Name');
if (nameError) return res.status(400).json({ error: nameError });
}
if (priority) {
const prioError = validators.enum(priority, ['low', 'medium', 'high'], 'Priorität');
if (prioError) return res.status(400).json({ error: prioError });
}
db.prepare(`
UPDATE task_templates SET
name = COALESCE(?, name),
title_template = ?,
description = ?,
priority = COALESCE(?, priority),
labels = ?,
subtasks = ?,
time_estimate_min = ?
WHERE id = ?
`).run(
name || null,
titleTemplate !== undefined ? titleTemplate : existing.title_template,
description !== undefined ? description : existing.description,
priority || null,
labels !== undefined ? JSON.stringify(labels) : existing.labels,
subtasks !== undefined ? JSON.stringify(subtasks) : existing.subtasks,
timeEstimateMin !== undefined ? timeEstimateMin : existing.time_estimate_min,
templateId
);
const template = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(templateId);
logger.info(`Vorlage aktualisiert: ${template.name} (ID: ${templateId})`);
res.json({
id: template.id,
projectId: template.project_id,
name: template.name,
titleTemplate: template.title_template,
description: template.description,
priority: template.priority,
labels: template.labels ? JSON.parse(template.labels) : [],
subtasks: template.subtasks ? JSON.parse(template.subtasks) : [],
timeEstimateMin: template.time_estimate_min
});
} catch (error) {
logger.error('Fehler beim Aktualisieren der Vorlage:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* POST /api/templates/:id/create-task
* Aufgabe aus Vorlage erstellen
*/
router.post('/:id/create-task', (req, res) => {
try {
const templateId = req.params.id;
const { columnId, title, assignedTo, dueDate } = req.body;
const db = getDb();
const template = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(templateId);
if (!template) {
return res.status(404).json({ error: 'Vorlage nicht gefunden' });
}
// columnId ist erforderlich
if (!columnId) {
return res.status(400).json({ error: 'Spalten-ID erforderlich' });
}
// Höchste Position ermitteln
const maxPos = db.prepare(
'SELECT COALESCE(MAX(position), -1) as max FROM tasks WHERE column_id = ?'
).get(columnId).max;
// Aufgabe erstellen
const taskTitle = title || template.title_template || 'Neue Aufgabe';
const result = db.prepare(`
INSERT INTO tasks (
project_id, column_id, title, description, priority,
due_date, assigned_to, time_estimate_min, position, created_by
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
template.project_id,
columnId,
taskTitle,
template.description,
template.priority || 'medium',
dueDate || null,
assignedTo || null,
template.time_estimate_min,
maxPos + 1,
req.user.id
);
const taskId = result.lastInsertRowid;
// Labels zuweisen
if (template.labels) {
const labelIds = JSON.parse(template.labels);
const insertLabel = db.prepare('INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)');
labelIds.forEach(labelId => {
try {
insertLabel.run(taskId, labelId);
} catch (e) { /* Label existiert nicht mehr */ }
});
}
// Subtasks erstellen
if (template.subtasks) {
const subtaskTitles = JSON.parse(template.subtasks);
const insertSubtask = db.prepare(
'INSERT INTO subtasks (task_id, title, position) VALUES (?, ?, ?)'
);
subtaskTitles.forEach((st, idx) => {
insertSubtask.run(taskId, st, idx);
});
}
// Historie
db.prepare(`
INSERT INTO history (task_id, user_id, action, new_value)
VALUES (?, ?, 'created', ?)
`).run(taskId, req.user.id, `Aus Vorlage: ${template.name}`);
logger.info(`Aufgabe aus Vorlage erstellt: ${taskTitle} (Vorlage: ${template.name})`);
// Vollständige Task-Daten laden (vereinfacht)
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
const labels = db.prepare(`
SELECT l.* FROM labels l
JOIN task_labels tl ON l.id = tl.label_id
WHERE tl.task_id = ?
`).all(taskId);
const subtasks = db.prepare('SELECT * FROM subtasks WHERE task_id = ? ORDER BY position').all(taskId);
// WebSocket
const io = req.app.get('io');
io.to(`project:${template.project_id}`).emit('task:created', {
id: task.id,
projectId: task.project_id,
columnId: task.column_id,
title: task.title,
description: task.description,
priority: task.priority,
dueDate: task.due_date,
assignedTo: task.assigned_to,
timeEstimateMin: task.time_estimate_min,
position: task.position,
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color })),
subtasks: subtasks.map(s => ({
id: s.id,
title: s.title,
completed: !!s.completed,
position: s.position
}))
});
res.status(201).json({
id: task.id,
projectId: task.project_id,
columnId: task.column_id,
title: task.title,
description: task.description,
priority: task.priority,
dueDate: task.due_date,
assignedTo: task.assigned_to,
timeEstimateMin: task.time_estimate_min,
position: task.position,
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color })),
subtasks: subtasks.map(s => ({
id: s.id,
title: s.title,
completed: !!s.completed,
position: s.position
}))
});
} catch (error) {
logger.error('Fehler beim Erstellen aus Vorlage:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
/**
* DELETE /api/templates/:id
* Vorlage löschen
*/
router.delete('/:id', (req, res) => {
try {
const templateId = req.params.id;
const db = getDb();
const template = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(templateId);
if (!template) {
return res.status(404).json({ error: 'Vorlage nicht gefunden' });
}
db.prepare('DELETE FROM task_templates WHERE id = ?').run(templateId);
logger.info(`Vorlage gelöscht: ${template.name} (ID: ${templateId})`);
res.json({ message: 'Vorlage gelöscht' });
} catch (error) {
logger.error('Fehler beim Löschen der Vorlage:', { error: error.message });
res.status(500).json({ error: 'Interner Serverfehler' });
}
});
module.exports = router;

307
backend/server.js Normale Datei
Datei anzeigen

@ -0,0 +1,307 @@
/**
* TASKMATE - Hauptserver
* ======================
* Node.js/Express Backend mit Socket.io für Echtzeit-Sync
*/
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const helmet = require('helmet');
const cors = require('cors');
const cookieParser = require('cookie-parser');
// Lokale Module
const database = require('./database');
const logger = require('./utils/logger');
const backup = require('./utils/backup');
const { authenticateToken, authenticateSocket } = require('./middleware/auth');
const csrfProtection = require('./middleware/csrf');
// Routes
const authRoutes = require('./routes/auth');
const projectRoutes = require('./routes/projects');
const columnRoutes = require('./routes/columns');
const taskRoutes = require('./routes/tasks');
const subtaskRoutes = require('./routes/subtasks');
const commentRoutes = require('./routes/comments');
const labelRoutes = require('./routes/labels');
const fileRoutes = require('./routes/files');
const linkRoutes = require('./routes/links');
const templateRoutes = require('./routes/templates');
const statsRoutes = require('./routes/stats');
const exportRoutes = require('./routes/export');
const importRoutes = require('./routes/import');
const healthRoutes = require('./routes/health');
const adminRoutes = require('./routes/admin');
const proposalRoutes = require('./routes/proposals');
const notificationRoutes = require('./routes/notifications');
const notificationService = require('./services/notificationService');
const gitRoutes = require('./routes/git');
const applicationsRoutes = require('./routes/applications');
const giteaRoutes = require('./routes/gitea');
// Express App erstellen
const app = express();
const server = http.createServer(app);
// Socket.io Setup
const io = new Server(server, {
cors: {
origin: true,
credentials: true
}
});
// =============================================================================
// MIDDLEWARE
// =============================================================================
// Sicherheits-Header
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:"]
}
}
}));
// CORS
app.use(cors({
origin: true,
credentials: true
}));
// Body Parser
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
// Cookie Parser
app.use(cookieParser());
// Request Logging
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
// Use originalUrl to see the full path including /api prefix
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
next();
});
// Statische Dateien (Frontend)
app.use(express.static(path.join(__dirname, 'public')));
// Uploads-Ordner
app.use('/uploads', authenticateToken, express.static(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads')));
// =============================================================================
// API ROUTES
// =============================================================================
// Health Check (ohne Auth)
app.use('/api/health', healthRoutes);
// Auth Routes (Login/Logout - teilweise ohne Auth)
app.use('/api/auth', authRoutes);
// Geschützte Routes
app.use('/api/projects', authenticateToken, csrfProtection, projectRoutes);
app.use('/api/columns', authenticateToken, csrfProtection, columnRoutes);
app.use('/api/tasks', authenticateToken, csrfProtection, taskRoutes);
app.use('/api/subtasks', authenticateToken, csrfProtection, subtaskRoutes);
app.use('/api/comments', authenticateToken, csrfProtection, commentRoutes);
app.use('/api/labels', authenticateToken, csrfProtection, labelRoutes);
app.use('/api/files', authenticateToken, fileRoutes);
app.use('/api/links', authenticateToken, csrfProtection, linkRoutes);
app.use('/api/templates', authenticateToken, csrfProtection, templateRoutes);
app.use('/api/stats', authenticateToken, statsRoutes);
app.use('/api/export', authenticateToken, exportRoutes);
app.use('/api/import', authenticateToken, csrfProtection, importRoutes);
// Admin-Routes (eigene Auth-Middleware)
app.use('/api/admin', csrfProtection, adminRoutes);
// Proposals-Routes (eigene Auth-Middleware)
app.use('/api/proposals', csrfProtection, proposalRoutes);
// Notifications-Routes
app.use('/api/notifications', authenticateToken, csrfProtection, notificationRoutes);
// Git-Routes (lokale Git-Operationen)
app.use('/api/git', authenticateToken, csrfProtection, gitRoutes);
// Applications-Routes (Projekt-Repository-Verknüpfung)
app.use('/api/applications', authenticateToken, csrfProtection, applicationsRoutes);
// Gitea-Routes (Gitea API Integration)
app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
// =============================================================================
// SOCKET.IO
// =============================================================================
// Socket.io Middleware für Authentifizierung
io.use(authenticateSocket);
// Verbundene Clients speichern
const connectedClients = new Map();
io.on('connection', (socket) => {
const userId = socket.user.id;
const username = socket.user.username;
logger.info(`Socket connected: ${username} (${socket.id})`);
// Client registrieren
connectedClients.set(socket.id, {
userId,
username,
connectedAt: new Date()
});
// User-spezifischen Raum beitreten (für Benachrichtigungen)
socket.join(`user:${userId}`);
// Allen mitteilen, dass jemand online ist
io.emit('user:online', {
userId,
username,
onlineUsers: Array.from(connectedClients.values()).map(c => ({
userId: c.userId,
username: c.username
}))
});
// Projekt-Raum beitreten
socket.on('project:join', (projectId) => {
socket.join(`project:${projectId}`);
logger.info(`${username} joined project:${projectId}`);
});
// Projekt-Raum verlassen
socket.on('project:leave', (projectId) => {
socket.leave(`project:${projectId}`);
logger.info(`${username} left project:${projectId}`);
});
// Disconnect
socket.on('disconnect', () => {
logger.info(`Socket disconnected: ${username} (${socket.id})`);
connectedClients.delete(socket.id);
// Allen mitteilen, dass jemand offline ist
io.emit('user:offline', {
userId,
username,
onlineUsers: Array.from(connectedClients.values()).map(c => ({
userId: c.userId,
username: c.username
}))
});
});
});
// Socket.io Instance global verfügbar machen für Routes
app.set('io', io);
// =============================================================================
// FEHLERBEHANDLUNG
// =============================================================================
// 404 Handler
app.use((req, res, next) => {
// API-Anfragen: JSON-Fehler
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Endpoint nicht gefunden' });
}
// Andere Anfragen: index.html (SPA)
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Globaler Error Handler
app.use((err, req, res, next) => {
logger.error(`Error: ${err.message}`, { stack: err.stack });
// CSRF-Fehler
if (err.code === 'CSRF_ERROR') {
return res.status(403).json({ error: 'Ungültiges CSRF-Token' });
}
// Multer-Fehler (Datei-Upload)
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({
error: `Datei zu groß. Maximum: ${process.env.MAX_FILE_SIZE_MB || 15} MB`
});
}
// Allgemeiner Fehler
res.status(err.status || 500).json({
error: process.env.NODE_ENV === 'production'
? 'Ein Fehler ist aufgetreten'
: err.message
});
});
// =============================================================================
// SERVER STARTEN
// =============================================================================
const PORT = process.env.PORT || 3000;
// Datenbank initialisieren
database.initialize()
.then(() => {
// Server starten
server.listen(PORT, () => {
logger.info(`Server läuft auf Port ${PORT}`);
logger.info(`Umgebung: ${process.env.NODE_ENV || 'development'}`);
// Backup-System starten
if (process.env.BACKUP_ENABLED !== 'false') {
backup.startScheduler();
logger.info('Automatische Backups aktiviert');
}
// Fälligkeits-Benachrichtigungen Scheduler (alle 6 Stunden)
setInterval(() => {
notificationService.checkDueTasks(io);
}, 6 * 60 * 60 * 1000);
// Erste Prüfung nach 1 Minute
setTimeout(() => {
notificationService.checkDueTasks(io);
logger.info('Fälligkeits-Check für Benachrichtigungen gestartet');
}, 60 * 1000);
});
})
.catch((err) => {
logger.error('Fehler beim Starten:', err);
process.exit(1);
});
// Graceful Shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM empfangen, fahre herunter...');
server.close(() => {
database.close();
logger.info('Server beendet');
process.exit(0);
});
});
process.on('SIGINT', () => {
logger.info('SIGINT empfangen, fahre herunter...');
server.close(() => {
database.close();
logger.info('Server beendet');
process.exit(0);
});
});

549
backend/services/gitService.js Normale Datei
Datei anzeigen

@ -0,0 +1,549 @@
/**
* TASKMATE - Git Service
* =======================
* Lokale Git-Operationen über child_process
*/
const { execSync, exec } = require('child_process');
const path = require('path');
const fs = require('fs');
const logger = require('../utils/logger');
/**
* Konvertiert einen Windows-Pfad zu einem Docker-Container-Pfad
* z.B. "D:\Projekte\MyApp" -> "/mnt/d/Projekte/MyApp"
*/
function windowsToContainerPath(windowsPath) {
if (!windowsPath) return null;
// Bereits ein Container-Pfad?
if (windowsPath.startsWith('/mnt/')) {
return windowsPath;
}
// Windows-Pfad konvertieren (z.B. "C:\foo" oder "C:/foo")
const normalized = windowsPath.replace(/\\/g, '/');
const match = normalized.match(/^([a-zA-Z]):[\/](.*)$/);
if (match) {
const drive = match[1].toLowerCase();
const restPath = match[2];
return `/mnt/${drive}/${restPath}`;
}
return windowsPath;
}
/**
* Konvertiert einen Docker-Container-Pfad zu einem Windows-Pfad
* z.B. "/mnt/d/Projekte/MyApp" -> "D:\Projekte\MyApp"
*/
function containerToWindowsPath(containerPath) {
if (!containerPath) return null;
const match = containerPath.match(/^\/mnt\/([a-z])\/(.*)$/);
if (match) {
const drive = match[1].toUpperCase();
const restPath = match[2].replace(/\//g, '\\');
return `${drive}:\\${restPath}`;
}
return containerPath;
}
/**
* Prüft, ob ein Pfad existiert und erreichbar ist
*/
function isPathAccessible(localPath) {
const containerPath = windowsToContainerPath(localPath);
try {
// Prüfe ob das übergeordnete Verzeichnis existiert
const parentDir = path.dirname(containerPath);
return fs.existsSync(parentDir);
} catch (error) {
logger.error('Pfadzugriff fehlgeschlagen:', error);
return false;
}
}
/**
* Prüft, ob ein Verzeichnis ein Git-Repository ist
*/
function isGitRepository(localPath) {
const containerPath = windowsToContainerPath(localPath);
try {
const gitDir = path.join(containerPath, '.git');
return fs.existsSync(gitDir);
} catch (error) {
return false;
}
}
/**
* Führt einen Git-Befehl aus
*/
function execGitCommand(command, cwd, options = {}) {
const containerPath = windowsToContainerPath(cwd);
const timeout = options.timeout || 60000; // 60 Sekunden Standard-Timeout
try {
const result = execSync(command, {
cwd: containerPath,
encoding: 'utf8',
timeout,
maxBuffer: 10 * 1024 * 1024, // 10 MB
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0', // Keine interaktiven Prompts
GIT_SSH_COMMAND: 'ssh -o StrictHostKeyChecking=no'
}
});
return { success: true, output: result.trim() };
} catch (error) {
logger.error(`Git-Befehl fehlgeschlagen: ${command}`, error.message);
return {
success: false,
error: error.message,
stderr: error.stderr?.toString() || ''
};
}
}
/**
* Repository klonen
*/
async function cloneRepository(repoUrl, localPath, options = {}) {
const containerPath = windowsToContainerPath(localPath);
const branch = options.branch || 'main';
// Prüfe, ob das Zielverzeichnis bereits existiert
if (fs.existsSync(containerPath)) {
if (isGitRepository(localPath)) {
return { success: false, error: 'Verzeichnis enthält bereits ein Git-Repository' };
}
// Verzeichnis existiert, aber ist kein Git-Repo - prüfe ob leer
const files = fs.readdirSync(containerPath);
if (files.length > 0) {
return { success: false, error: 'Verzeichnis ist nicht leer' };
}
} else {
// Erstelle Verzeichnis
fs.mkdirSync(containerPath, { recursive: true });
}
// Gitea-Token für Authentifizierung hinzufügen
let authUrl = repoUrl;
const giteaToken = process.env.GITEA_TOKEN;
if (giteaToken && repoUrl.includes('gitea')) {
// Füge Token zur URL hinzu: https://token@gitea.example.com/...
authUrl = repoUrl.replace('https://', `https://${giteaToken}@`);
}
const command = `git clone --branch ${branch} "${authUrl}" "${containerPath}"`;
try {
execSync(command, {
encoding: 'utf8',
timeout: 300000, // 5 Minuten für Clone
maxBuffer: 50 * 1024 * 1024, // 50 MB
env: {
...process.env,
GIT_TERMINAL_PROMPT: '0'
}
});
logger.info(`Repository geklont: ${repoUrl} -> ${localPath}`);
return { success: true, message: 'Repository erfolgreich geklont' };
} catch (error) {
logger.error('Clone fehlgeschlagen:', error.message);
// Bereinige bei Fehler
try {
if (fs.existsSync(containerPath)) {
fs.rmSync(containerPath, { recursive: true, force: true });
}
} catch (e) {
// Ignorieren
}
return { success: false, error: error.message };
}
}
/**
* Änderungen ziehen (Pull)
*/
function pullChanges(localPath, options = {}) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const branch = options.branch || '';
const command = branch ? `git pull origin ${branch}` : 'git pull';
return execGitCommand(command, localPath);
}
/**
* Änderungen hochladen (Push)
*/
function pushChanges(localPath, options = {}) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const branch = options.branch || '';
const command = branch ? `git push origin ${branch}` : 'git push';
return execGitCommand(command, localPath, { timeout: 120000 });
}
/**
* Git-Status abrufen
*/
function getStatus(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const result = execGitCommand('git status --porcelain', localPath);
if (!result.success) {
return result;
}
const lines = result.output.split('\n').filter(l => l.trim());
const changes = lines.map(line => {
const status = line.substring(0, 2);
const file = line.substring(3);
return { status, file };
});
// Zusätzliche Infos
const branchResult = execGitCommand('git branch --show-current', localPath);
const aheadBehindResult = execGitCommand('git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo "0 0"', localPath);
let ahead = 0;
let behind = 0;
if (aheadBehindResult.success) {
const parts = aheadBehindResult.output.split(/\s+/);
ahead = parseInt(parts[0]) || 0;
behind = parseInt(parts[1]) || 0;
}
return {
success: true,
branch: branchResult.success ? branchResult.output : 'unknown',
changes,
hasChanges: changes.length > 0,
ahead,
behind,
isClean: changes.length === 0 && ahead === 0
};
}
/**
* Alle Änderungen stagen
*/
function stageAll(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
return execGitCommand('git add -A', localPath);
}
/**
* Commit erstellen
*/
function commit(localPath, message) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
if (!message || message.trim() === '') {
return { success: false, error: 'Commit-Nachricht erforderlich' };
}
// Escape für Shell
const escapedMessage = message.replace(/"/g, '\\"');
return execGitCommand(`git commit -m "${escapedMessage}"`, localPath);
}
/**
* Commit-Historie abrufen
*/
function getCommitHistory(localPath, limit = 20) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const format = '%H|%h|%an|%ae|%at|%s';
const result = execGitCommand(`git log -${limit} --format="${format}"`, localPath);
if (!result.success) {
return result;
}
const commits = result.output.split('\n').filter(l => l.trim()).map(line => {
const [hash, shortHash, author, email, timestamp, subject] = line.split('|');
return {
hash,
shortHash,
author,
email,
date: new Date(parseInt(timestamp) * 1000).toISOString(),
message: subject
};
});
return { success: true, commits };
}
/**
* Branches auflisten
*/
function getBranches(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const result = execGitCommand('git branch -a', localPath);
if (!result.success) {
return result;
}
const branches = result.output.split('\n').filter(l => l.trim()).map(line => {
const isCurrent = line.startsWith('*');
const name = line.replace(/^\*?\s+/, '').trim();
const isRemote = name.startsWith('remotes/');
return { name, isCurrent, isRemote };
});
return { success: true, branches };
}
/**
* Branch wechseln
*/
function checkoutBranch(localPath, branch) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
return execGitCommand(`git checkout ${branch}`, localPath);
}
/**
* Fetch von Remote
*/
function fetchRemote(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
return execGitCommand('git fetch --all', localPath, { timeout: 120000 });
}
/**
* Remote-URL abrufen
*/
function getRemoteUrl(localPath) {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
const result = execGitCommand('git remote get-url origin', localPath);
if (result.success) {
// Entferne Token aus URL für Anzeige
let url = result.output;
url = url.replace(/https:\/\/[^@]+@/, 'https://');
return { success: true, url };
}
return result;
}
/**
* Git Repository initialisieren
*/
function initRepository(localPath, options = {}) {
const containerPath = windowsToContainerPath(localPath);
const branch = options.branch || 'main';
// Prüfe ob bereits ein Git-Repo existiert
if (isGitRepository(localPath)) {
return { success: true, message: 'Git-Repository existiert bereits' };
}
// Prüfe ob Verzeichnis existiert
if (!fs.existsSync(containerPath)) {
return { success: false, error: 'Verzeichnis existiert nicht' };
}
// Git init ausführen
const initResult = execGitCommand(`git init -b ${branch}`, localPath);
if (!initResult.success) {
return initResult;
}
logger.info(`Git-Repository initialisiert: ${localPath}`);
return { success: true, message: 'Git-Repository initialisiert' };
}
/**
* Remote hinzufügen oder aktualisieren
*/
function setRemote(localPath, remoteUrl, remoteName = 'origin') {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
// Gitea-Token für Authentifizierung hinzufügen
let authUrl = remoteUrl;
const giteaToken = process.env.GITEA_TOKEN;
if (giteaToken && (remoteUrl.includes('gitea') || remoteUrl.includes('aegis-sight'))) {
authUrl = remoteUrl.replace('https://', `https://${giteaToken}@`);
}
// Prüfe ob Remote bereits existiert
const checkResult = execGitCommand(`git remote get-url ${remoteName}`, localPath);
if (checkResult.success) {
// Remote existiert - aktualisieren
const result = execGitCommand(`git remote set-url ${remoteName} "${authUrl}"`, localPath);
if (result.success) {
logger.info(`Remote '${remoteName}' aktualisiert: ${remoteUrl}`);
return { success: true, message: 'Remote aktualisiert' };
}
return result;
} else {
// Remote existiert nicht - hinzufügen
const result = execGitCommand(`git remote add ${remoteName} "${authUrl}"`, localPath);
if (result.success) {
logger.info(`Remote '${remoteName}' hinzugefügt: ${remoteUrl}`);
return { success: true, message: 'Remote hinzugefügt' };
}
return result;
}
}
/**
* Prüft ob ein Remote existiert
*/
function hasRemote(localPath, remoteName = 'origin') {
if (!isGitRepository(localPath)) {
return false;
}
const result = execGitCommand(`git remote get-url ${remoteName}`, localPath);
return result.success;
}
/**
* Initialen Push mit Upstream-Tracking
*/
function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
if (!isGitRepository(localPath)) {
return { success: false, error: 'Kein Git-Repository' };
}
// Aktuellen Branch ermitteln falls nicht angegeben
if (!branch) {
const branchResult = execGitCommand('git branch --show-current', localPath);
branch = branchResult.success && branchResult.output ? branchResult.output : 'main';
}
// Prüfe ob Commits existieren
const logResult = execGitCommand('git rev-parse HEAD', localPath);
if (!logResult.success) {
// Keine Commits - erstelle einen initialen Commit
const statusResult = execGitCommand('git status --porcelain', localPath);
if (statusResult.success && statusResult.output.trim()) {
// Es gibt Dateien - stage und commit
const stageResult = stageAll(localPath);
if (!stageResult.success) {
return { success: false, error: 'Staging fehlgeschlagen: ' + stageResult.error };
}
const commitResult = commit(localPath, 'Initial commit');
if (!commitResult.success) {
return { success: false, error: 'Initial commit fehlgeschlagen: ' + commitResult.error };
}
logger.info('Initialer Commit erstellt vor Push');
} else {
return { success: false, error: 'Keine Commits und keine Dateien zum Committen vorhanden' };
}
}
// Push mit -u für Upstream-Tracking
return execGitCommand(`git push -u ${remoteName} ${branch}`, localPath, { timeout: 120000 });
}
/**
* Repository für Gitea vorbereiten (init, remote, initial commit)
*/
function prepareForGitea(localPath, remoteUrl, options = {}) {
const branch = options.branch || 'main';
const containerPath = windowsToContainerPath(localPath);
// 1. Prüfe ob Git-Repo existiert, wenn nicht initialisieren
if (!isGitRepository(localPath)) {
const initResult = initRepository(localPath, { branch });
if (!initResult.success) {
return initResult;
}
}
// 2. Remote hinzufügen/aktualisieren
const remoteResult = setRemote(localPath, remoteUrl);
if (!remoteResult.success) {
return remoteResult;
}
// 3. Prüfe ob Commits existieren
const logResult = execGitCommand('git rev-parse HEAD', localPath);
if (!logResult.success) {
// Keine Commits - erstelle initialen Commit wenn Dateien vorhanden
const statusResult = execGitCommand('git status --porcelain', localPath);
if (statusResult.success && statusResult.output.trim()) {
// Es gibt Dateien - stage und commit
const stageResult = stageAll(localPath);
if (!stageResult.success) {
return stageResult;
}
const commitResult = commit(localPath, 'Initial commit');
if (!commitResult.success) {
return commitResult;
}
}
}
logger.info(`Repository für Gitea vorbereitet: ${localPath} -> ${remoteUrl}`);
return { success: true, message: 'Repository für Gitea vorbereitet' };
}
module.exports = {
windowsToContainerPath,
containerToWindowsPath,
isPathAccessible,
isGitRepository,
cloneRepository,
pullChanges,
pushChanges,
getStatus,
stageAll,
commit,
getCommitHistory,
getBranches,
checkoutBranch,
fetchRemote,
getRemoteUrl,
initRepository,
setRemote,
hasRemote,
pushWithUpstream,
prepareForGitea
};

Datei anzeigen

@ -0,0 +1,300 @@
/**
* TASKMATE - Gitea Service
* =========================
* Integration mit Gitea API
*/
const logger = require('../utils/logger');
const GITEA_URL = process.env.GITEA_URL || 'https://gitea-undso.aegis-sight.de';
const GITEA_TOKEN = process.env.GITEA_TOKEN;
const GITEA_ORG = process.env.GITEA_ORG || 'AegisSight'; // Standard-Organisation für neue Repos
/**
* Basis-Fetch für Gitea API
*/
async function giteaFetch(endpoint, options = {}) {
if (!GITEA_TOKEN) {
throw new Error('Gitea-Token nicht konfiguriert');
}
const url = `${GITEA_URL}/api/v1${endpoint}`;
const response = await fetch(url, {
...options,
headers: {
'Authorization': `token ${GITEA_TOKEN}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
...options.headers
}
});
if (!response.ok) {
const errorText = await response.text();
logger.error(`Gitea API Fehler: ${response.status} - ${errorText}`);
throw new Error(`Gitea API Fehler: ${response.status}`);
}
return response.json();
}
/**
* Alle Repositories der Organisation abrufen
*/
async function listRepositories(options = {}) {
try {
const page = options.page || 1;
const limit = options.limit || 50;
// Repositories der Organisation abrufen
const repos = await giteaFetch(`/orgs/${GITEA_ORG}/repos?page=${page}&limit=${limit}`);
return {
success: true,
repositories: repos.map(repo => ({
id: repo.id,
name: repo.name,
fullName: repo.full_name,
owner: repo.owner.login,
description: repo.description || '',
cloneUrl: repo.clone_url,
htmlUrl: repo.html_url,
defaultBranch: repo.default_branch,
private: repo.private,
fork: repo.fork,
stars: repo.stars_count,
forks: repo.forks_count,
updatedAt: repo.updated_at,
createdAt: repo.created_at
}))
};
} catch (error) {
logger.error('Fehler beim Abrufen der Repositories:', error);
return { success: false, error: error.message };
}
}
/**
* Repository-Details abrufen
*/
async function getRepository(owner, repo) {
try {
const repoData = await giteaFetch(`/repos/${owner}/${repo}`);
return {
success: true,
repository: {
id: repoData.id,
name: repoData.name,
fullName: repoData.full_name,
owner: repoData.owner.login,
description: repoData.description || '',
cloneUrl: repoData.clone_url,
htmlUrl: repoData.html_url,
defaultBranch: repoData.default_branch,
private: repoData.private,
fork: repoData.fork,
stars: repoData.stars_count,
forks: repoData.forks_count,
size: repoData.size,
updatedAt: repoData.updated_at,
createdAt: repoData.created_at
}
};
} catch (error) {
logger.error(`Fehler beim Abrufen des Repositories ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
/**
* Branches eines Repositories abrufen
*/
async function getRepositoryBranches(owner, repo) {
try {
const branches = await giteaFetch(`/repos/${owner}/${repo}/branches`);
return {
success: true,
branches: branches.map(branch => ({
name: branch.name,
commit: branch.commit?.id,
protected: branch.protected
}))
};
} catch (error) {
logger.error(`Fehler beim Abrufen der Branches für ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
/**
* Commits eines Repositories abrufen
*/
async function getRepositoryCommits(owner, repo, options = {}) {
try {
const page = options.page || 1;
const limit = options.limit || 20;
const branch = options.branch || '';
let endpoint = `/repos/${owner}/${repo}/commits?page=${page}&limit=${limit}`;
if (branch) {
endpoint += `&sha=${branch}`;
}
const commits = await giteaFetch(endpoint);
return {
success: true,
commits: commits.map(commit => ({
sha: commit.sha,
shortSha: commit.sha.substring(0, 7),
message: commit.commit.message,
author: commit.commit.author.name,
email: commit.commit.author.email,
date: commit.commit.author.date,
htmlUrl: commit.html_url
}))
};
} catch (error) {
logger.error(`Fehler beim Abrufen der Commits für ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
/**
* Neues Repository in der Organisation erstellen
*/
async function createRepository(name, options = {}) {
try {
// Repository unter der Organisation erstellen
const repoData = await giteaFetch(`/orgs/${GITEA_ORG}/repos`, {
method: 'POST',
body: JSON.stringify({
name,
description: options.description || '',
private: options.private !== false,
auto_init: options.autoInit !== false,
default_branch: options.defaultBranch || 'main',
readme: options.readme || 'Default'
})
});
logger.info(`Repository in Organisation ${GITEA_ORG} erstellt: ${repoData.full_name}`);
return {
success: true,
repository: {
id: repoData.id,
name: repoData.name,
fullName: repoData.full_name,
owner: repoData.owner.login,
cloneUrl: repoData.clone_url,
htmlUrl: repoData.html_url,
defaultBranch: repoData.default_branch
}
};
} catch (error) {
logger.error('Fehler beim Erstellen des Repositories:', error);
return { success: false, error: error.message };
}
}
/**
* Repository löschen
*/
async function deleteRepository(owner, repo) {
try {
await giteaFetch(`/repos/${owner}/${repo}`, {
method: 'DELETE'
});
logger.info(`Repository gelöscht: ${owner}/${repo}`);
return { success: true };
} catch (error) {
logger.error(`Fehler beim Löschen des Repositories ${owner}/${repo}:`, error);
return { success: false, error: error.message };
}
}
/**
* Authentifizierten Benutzer abrufen
*/
async function getCurrentUser() {
try {
const user = await giteaFetch('/user');
return {
success: true,
user: {
id: user.id,
login: user.login,
fullName: user.full_name,
email: user.email,
avatarUrl: user.avatar_url
}
};
} catch (error) {
logger.error('Fehler beim Abrufen des aktuellen Benutzers:', error);
return { success: false, error: error.message };
}
}
/**
* Prüft ob die Gitea-Verbindung funktioniert
*/
async function testConnection() {
try {
const result = await getCurrentUser();
return {
success: result.success,
connected: result.success,
user: result.user,
giteaUrl: GITEA_URL,
organization: GITEA_ORG
};
} catch (error) {
return {
success: false,
connected: false,
error: error.message,
giteaUrl: GITEA_URL,
organization: GITEA_ORG
};
}
}
/**
* Clone-URL mit Token für private Repos
*/
function getAuthenticatedCloneUrl(cloneUrl) {
if (!GITEA_TOKEN) {
return cloneUrl;
}
// Füge Token zur URL hinzu
return cloneUrl.replace('https://', `https://${GITEA_TOKEN}@`);
}
/**
* Gitea-URL ohne Token (für Anzeige)
*/
function getSafeCloneUrl(cloneUrl) {
// Entferne Token aus URL falls vorhanden
return cloneUrl.replace(/https:\/\/[^@]+@/, 'https://');
}
module.exports = {
listRepositories,
getRepository,
getRepositoryBranches,
getRepositoryCommits,
createRepository,
deleteRepository,
getCurrentUser,
testConnection,
getAuthenticatedCloneUrl,
getSafeCloneUrl,
GITEA_URL,
GITEA_ORG
};

Datei anzeigen

@ -0,0 +1,290 @@
/**
* TASKMATE - Notification Service
* ================================
* Zentrale Logik für das Benachrichtigungssystem
*/
const { getDb } = require('../database');
const logger = require('../utils/logger');
/**
* Benachrichtigungstypen mit Titeln und Icons
*/
const NOTIFICATION_TYPES = {
'task:assigned': {
title: (data) => 'Neue Aufgabe zugewiesen',
message: (data) => `Du wurdest der Aufgabe "${data.taskTitle}" zugewiesen`
},
'task:unassigned': {
title: (data) => 'Zuweisung entfernt',
message: (data) => `Du wurdest von der Aufgabe "${data.taskTitle}" entfernt`
},
'task:due_soon': {
title: (data) => 'Aufgabe bald fällig',
message: (data) => `Die Aufgabe "${data.taskTitle}" ist morgen fällig`
},
'task:completed': {
title: (data) => 'Aufgabe erledigt',
message: (data) => `Die Aufgabe "${data.taskTitle}" wurde erledigt`
},
'task:due_changed': {
title: (data) => 'Fälligkeitsdatum geändert',
message: (data) => `Das Fälligkeitsdatum von "${data.taskTitle}" wurde geändert`
},
'task:priority_up': {
title: (data) => 'Priorität erhöht',
message: (data) => `Die Priorität von "${data.taskTitle}" wurde auf "Hoch" gesetzt`
},
'comment:created': {
title: (data) => 'Neuer Kommentar',
message: (data) => `${data.actorName} hat "${data.taskTitle}" kommentiert`
},
'comment:mention': {
title: (data) => 'Du wurdest erwähnt',
message: (data) => `${data.actorName} hat dich in "${data.taskTitle}" erwähnt`
},
'approval:pending': {
title: (data) => 'Genehmigung erforderlich',
message: (data) => `Neue Genehmigung: "${data.proposalTitle}"`
},
'approval:granted': {
title: (data) => 'Genehmigung erteilt',
message: (data) => `"${data.proposalTitle}" wurde genehmigt`
},
'approval:rejected': {
title: (data) => 'Genehmigung abgelehnt',
message: (data) => `"${data.proposalTitle}" wurde abgelehnt`
}
};
const notificationService = {
/**
* Benachrichtigung erstellen und per WebSocket senden
* @param {number} userId - Empfänger
* @param {string} type - Benachrichtigungstyp
* @param {object} data - Zusätzliche Daten
* @param {object} io - Socket.io Instanz
* @param {boolean} persistent - Ob die Benachrichtigung persistent ist
*/
create(userId, type, data, io, persistent = false) {
try {
const db = getDb();
const typeConfig = NOTIFICATION_TYPES[type];
if (!typeConfig) {
logger.warn(`Unbekannter Benachrichtigungstyp: ${type}`);
return null;
}
const title = typeConfig.title(data);
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 (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
userId,
type,
title,
message,
data.taskId || null,
data.projectId || null,
data.proposalId || null,
data.actorId || null,
persistent ? 1 : 0
);
const notification = db.prepare(`
SELECT n.*, u.display_name as actor_name, u.color as actor_color
FROM notifications n
LEFT JOIN users u ON n.actor_id = u.id
WHERE n.id = ?
`).get(result.lastInsertRowid);
// WebSocket-Event senden
if (io) {
io.to(`user:${userId}`).emit('notification:new', {
notification: this.formatNotification(notification)
});
// Auch aktualisierte Zählung senden
const count = this.getUnreadCount(userId);
io.to(`user:${userId}`).emit('notification:count', { count });
}
logger.info(`Benachrichtigung erstellt: ${type} für User ${userId}`);
return notification;
} catch (error) {
logger.error('Fehler beim Erstellen der Benachrichtigung:', error);
return null;
}
},
/**
* Alle Benachrichtigungen für einen User abrufen
*/
getForUser(userId, limit = 50) {
const db = getDb();
const notifications = db.prepare(`
SELECT n.*, u.display_name as actor_name, u.color as actor_color
FROM notifications n
LEFT JOIN users u ON n.actor_id = u.id
WHERE n.user_id = ?
ORDER BY n.is_persistent DESC, n.created_at DESC
LIMIT ?
`).all(userId, limit);
return notifications.map(n => this.formatNotification(n));
},
/**
* Ungelesene Anzahl ermitteln
*/
getUnreadCount(userId) {
const db = getDb();
const result = db.prepare(`
SELECT COUNT(*) as count
FROM notifications
WHERE user_id = ? AND is_read = 0
`).get(userId);
return result.count;
},
/**
* Als gelesen markieren
*/
markAsRead(notificationId, userId) {
const db = getDb();
const result = db.prepare(`
UPDATE notifications
SET is_read = 1
WHERE id = ? AND user_id = ?
`).run(notificationId, userId);
return result.changes > 0;
},
/**
* Alle als gelesen markieren
*/
markAllAsRead(userId) {
const db = getDb();
const result = db.prepare(`
UPDATE notifications
SET is_read = 1
WHERE user_id = ? AND is_read = 0
`).run(userId);
return result.changes;
},
/**
* Benachrichtigung löschen (nur nicht-persistente)
*/
delete(notificationId, userId) {
const db = getDb();
const result = db.prepare(`
DELETE FROM notifications
WHERE id = ? AND user_id = ? AND is_persistent = 0
`).run(notificationId, userId);
return result.changes > 0;
},
/**
* Persistente Benachrichtigungen auflösen (z.B. bei Genehmigung)
*/
resolvePersistent(proposalId) {
const db = getDb();
const result = db.prepare(`
DELETE FROM notifications
WHERE proposal_id = ? AND is_persistent = 1
`).run(proposalId);
logger.info(`${result.changes} persistente Benachrichtigungen für Proposal ${proposalId} aufgelöst`);
return result.changes;
},
/**
* Fälligkeits-Check für Aufgaben (1 Tag vorher)
*/
checkDueTasks(io) {
try {
const db = getDb();
// Morgen berechnen
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().split('T')[0];
// Aufgaben die morgen fällig sind
const tasks = db.prepare(`
SELECT t.id, t.title, t.project_id, ta.user_id as assignee_id
FROM tasks t
JOIN task_assignees ta ON t.id = ta.task_id
LEFT JOIN columns c ON t.column_id = c.id
WHERE t.due_date = ?
AND t.archived = 0
AND c.filter_category != 'completed'
AND NOT EXISTS (
SELECT 1 FROM notifications n
WHERE n.task_id = t.id
AND n.user_id = ta.user_id
AND n.type = 'task:due_soon'
AND DATE(n.created_at) = DATE('now')
)
`).all(tomorrowStr);
let count = 0;
tasks.forEach(task => {
this.create(task.assignee_id, 'task:due_soon', {
taskId: task.id,
taskTitle: task.title,
projectId: task.project_id
}, io);
count++;
});
if (count > 0) {
logger.info(`${count} Fälligkeits-Benachrichtigungen erstellt`);
}
return count;
} catch (error) {
logger.error('Fehler beim Fälligkeits-Check:', error);
return 0;
}
},
/**
* Benachrichtigung formatieren für Frontend
*/
formatNotification(notification) {
return {
id: notification.id,
userId: notification.user_id,
type: notification.type,
title: notification.title,
message: notification.message,
taskId: notification.task_id,
projectId: notification.project_id,
proposalId: notification.proposal_id,
actorId: notification.actor_id,
actorName: notification.actor_name,
actorColor: notification.actor_color,
isRead: notification.is_read === 1,
isPersistent: notification.is_persistent === 1,
createdAt: notification.created_at
};
},
/**
* Benachrichtigung an mehrere User senden
*/
createForMultiple(userIds, type, data, io, persistent = false) {
const results = [];
userIds.forEach(userId => {
const result = this.create(userId, type, data, io, persistent);
if (result) results.push(result);
});
return results;
}
};
module.exports = notificationService;

183
backend/utils/backup.js Normale Datei
Datei anzeigen

@ -0,0 +1,183 @@
/**
* TASKMATE - Backup System
* ========================
* Automatische Datenbank-Backups
*/
const fs = require('fs');
const path = require('path');
const logger = require('./logger');
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
const BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
const DB_FILE = path.join(DATA_DIR, 'taskmate.db');
// Backup-Verzeichnis erstellen falls nicht vorhanden
if (!fs.existsSync(BACKUP_DIR)) {
fs.mkdirSync(BACKUP_DIR, { recursive: true });
}
/**
* Backup erstellen
*/
function createBackup() {
try {
if (!fs.existsSync(DB_FILE)) {
logger.warn('Keine Datenbank zum Sichern gefunden');
return null;
}
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const backupName = `backup_${timestamp}.db`;
const backupPath = path.join(BACKUP_DIR, backupName);
// Datenbank kopieren
fs.copyFileSync(DB_FILE, backupPath);
// WAL-Datei auch sichern falls vorhanden
const walFile = DB_FILE + '-wal';
if (fs.existsSync(walFile)) {
fs.copyFileSync(walFile, backupPath + '-wal');
}
logger.info(`Backup erstellt: ${backupName}`);
// Alte Backups aufräumen (behalte nur die letzten 30)
cleanupOldBackups(30);
return backupPath;
} catch (error) {
logger.error('Backup-Fehler:', { error: error.message });
return null;
}
}
/**
* Alte Backups löschen
*/
function cleanupOldBackups(keepCount = 30) {
try {
const files = fs.readdirSync(BACKUP_DIR)
.filter(f => f.startsWith('backup_') && f.endsWith('.db'))
.sort()
.reverse();
const toDelete = files.slice(keepCount);
toDelete.forEach(file => {
const filePath = path.join(BACKUP_DIR, file);
fs.unlinkSync(filePath);
// WAL-Datei auch löschen falls vorhanden
const walPath = filePath + '-wal';
if (fs.existsSync(walPath)) {
fs.unlinkSync(walPath);
}
logger.info(`Altes Backup gelöscht: ${file}`);
});
} catch (error) {
logger.error('Fehler beim Aufräumen alter Backups:', { error: error.message });
}
}
/**
* Backup wiederherstellen
*/
function restoreBackup(backupName) {
try {
const backupPath = path.join(BACKUP_DIR, backupName);
if (!fs.existsSync(backupPath)) {
throw new Error(`Backup nicht gefunden: ${backupName}`);
}
// Aktuelles DB sichern bevor überschrieben wird
if (fs.existsSync(DB_FILE)) {
const safetyBackup = DB_FILE + '.before-restore';
fs.copyFileSync(DB_FILE, safetyBackup);
}
// Backup wiederherstellen
fs.copyFileSync(backupPath, DB_FILE);
// WAL-Datei auch wiederherstellen falls vorhanden
const walBackup = backupPath + '-wal';
if (fs.existsSync(walBackup)) {
fs.copyFileSync(walBackup, DB_FILE + '-wal');
}
logger.info(`Backup wiederhergestellt: ${backupName}`);
return true;
} catch (error) {
logger.error('Restore-Fehler:', { error: error.message });
throw error;
}
}
/**
* Liste aller Backups
*/
function listBackups() {
try {
const files = fs.readdirSync(BACKUP_DIR)
.filter(f => f.startsWith('backup_') && f.endsWith('.db'))
.map(f => {
const filePath = path.join(BACKUP_DIR, f);
const stats = fs.statSync(filePath);
return {
name: f,
size: stats.size,
created: stats.birthtime
};
})
.sort((a, b) => b.created - a.created);
return files;
} catch (error) {
logger.error('Fehler beim Auflisten der Backups:', { error: error.message });
return [];
}
}
/**
* Backup-Scheduler starten
*/
let schedulerInterval = null;
function startScheduler() {
const intervalHours = parseInt(process.env.BACKUP_INTERVAL_HOURS) || 24;
const intervalMs = intervalHours * 60 * 60 * 1000;
// Erstes Backup nach 1 Minute
setTimeout(() => {
createBackup();
}, 60 * 1000);
// Regelmäßige Backups
schedulerInterval = setInterval(() => {
createBackup();
}, intervalMs);
logger.info(`Backup-Scheduler gestartet (alle ${intervalHours} Stunden)`);
}
/**
* Backup-Scheduler stoppen
*/
function stopScheduler() {
if (schedulerInterval) {
clearInterval(schedulerInterval);
schedulerInterval = null;
logger.info('Backup-Scheduler gestoppt');
}
}
module.exports = {
createBackup,
restoreBackup,
listBackups,
startScheduler,
stopScheduler,
cleanupOldBackups
};

94
backend/utils/logger.js Normale Datei
Datei anzeigen

@ -0,0 +1,94 @@
/**
* TASKMATE - Logger
* =================
* Einfaches Logging mit Datei-Ausgabe
*/
const fs = require('fs');
const path = require('path');
const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, '..', 'logs');
const LOG_FILE = path.join(LOG_DIR, 'app.log');
// Log-Verzeichnis erstellen falls nicht vorhanden
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
/**
* Log-Level
*/
const LEVELS = {
ERROR: 'ERROR',
WARN: 'WARN',
INFO: 'INFO',
DEBUG: 'DEBUG'
};
/**
* Timestamp formatieren
*/
function getTimestamp() {
return new Date().toISOString();
}
/**
* Log-Nachricht schreiben
*/
function log(level, message, meta = {}) {
const timestamp = getTimestamp();
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
const logLine = `[${timestamp}] [${level}] ${message}${metaStr}`;
// Konsole
if (level === LEVELS.ERROR) {
console.error(logLine);
} else if (level === LEVELS.WARN) {
console.warn(logLine);
} else {
console.log(logLine);
}
// Datei (async, non-blocking)
fs.appendFile(LOG_FILE, logLine + '\n', (err) => {
if (err) console.error('Log-Datei Fehler:', err);
});
}
/**
* Log-Rotation (alte Logs löschen)
*/
function rotateLogsIfNeeded() {
try {
const stats = fs.statSync(LOG_FILE);
const maxSize = 10 * 1024 * 1024; // 10 MB
if (stats.size > maxSize) {
const archiveName = `app.${Date.now()}.log`;
fs.renameSync(LOG_FILE, path.join(LOG_DIR, archiveName));
// Alte Archive löschen (behalte nur die letzten 5)
const files = fs.readdirSync(LOG_DIR)
.filter(f => f.startsWith('app.') && f.endsWith('.log') && f !== 'app.log')
.sort()
.reverse();
files.slice(5).forEach(f => {
fs.unlinkSync(path.join(LOG_DIR, f));
});
}
} catch (err) {
// Datei existiert noch nicht, ignorieren
}
}
// Log-Rotation beim Start und alle 6 Stunden prüfen
rotateLogsIfNeeded();
setInterval(rotateLogsIfNeeded, 6 * 60 * 60 * 1000);
module.exports = {
error: (message, meta) => log(LEVELS.ERROR, message, meta),
warn: (message, meta) => log(LEVELS.WARN, message, meta),
info: (message, meta) => log(LEVELS.INFO, message, meta),
debug: (message, meta) => log(LEVELS.DEBUG, message, meta)
};

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.

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen