Initial commit
Dieser Commit ist enthalten in:
29
.claude/settings.local.json
Normale Datei
29
.claude/settings.local.json
Normale Datei
@ -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
53
.env
Normale Datei
@ -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
542
ANWENDUNGSBESCHREIBUNG.txt
Normale Datei
@ -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
1239
CHANGELOG.txt
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
55
CLAUDE.md
Normale Datei
55
CLAUDE.md
Normale Datei
@ -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
118
CLAUDE_PROJECT_README.md
Normale Datei
@ -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
48
Dockerfile
Normale Datei
@ -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
357
GITEA_IMPLEMENTIERUNGSPLAN.txt
Normale Datei
@ -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
398
SERVER_SETUP_ANLEITUNG.md
Normale Datei
@ -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
536
backend/database.js
Normale Datei
@ -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
189
backend/middleware/auth.js
Normale Datei
@ -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
125
backend/middleware/csrf.js
Normale Datei
@ -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
198
backend/middleware/upload.js
Normale Datei
@ -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
|
||||
};
|
||||
249
backend/middleware/validation.js
Normale Datei
249
backend/middleware/validation.js
Normale Datei
@ -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
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
30
backend/package.json
Normale Datei
@ -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
409
backend/routes/admin.js
Normale Datei
@ -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
212
backend/routes/applications.js
Normale Datei
@ -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
319
backend/routes/auth.js
Normale Datei
@ -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
302
backend/routes/columns.js
Normale Datei
@ -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
279
backend/routes/comments.js
Normale Datei
@ -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
230
backend/routes/export.js
Normale Datei
@ -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
238
backend/routes/files.js
Normale Datei
@ -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
444
backend/routes/git.js
Normale Datei
@ -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
160
backend/routes/gitea.js
Normale Datei
@ -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
158
backend/routes/health.js
Normale Datei
@ -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
269
backend/routes/import.js
Normale Datei
@ -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
202
backend/routes/labels.js
Normale Datei
@ -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
253
backend/routes/links.js
Normale Datei
@ -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;
|
||||
134
backend/routes/notifications.js
Normale Datei
134
backend/routes/notifications.js
Normale Datei
@ -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
359
backend/routes/projects.js
Normale Datei
@ -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
299
backend/routes/proposals.js
Normale Datei
@ -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
310
backend/routes/stats.js
Normale Datei
@ -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
279
backend/routes/subtasks.js
Normale Datei
@ -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
899
backend/routes/tasks.js
Normale Datei
@ -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
336
backend/routes/templates.js
Normale Datei
@ -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
307
backend/server.js
Normale Datei
@ -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
549
backend/services/gitService.js
Normale Datei
@ -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
|
||||
};
|
||||
300
backend/services/giteaService.js
Normale Datei
300
backend/services/giteaService.js
Normale Datei
@ -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
|
||||
};
|
||||
290
backend/services/notificationService.js
Normale Datei
290
backend/services/notificationService.js
Normale Datei
@ -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
183
backend/utils/backup.js
Normale Datei
@ -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
94
backend/utils/logger.js
Normale Datei
@ -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
backups/backup_2025-12-22T11-19-54-427Z.db
Normale Datei
BIN
backups/backup_2025-12-22T11-19-54-427Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T11-19-54-427Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T11-19-54-427Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T11-29-31-161Z.db
Normale Datei
BIN
backups/backup_2025-12-22T11-29-31-161Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T11-29-31-161Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T11-29-31-161Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T11-33-43-528Z.db
Normale Datei
BIN
backups/backup_2025-12-22T11-33-43-528Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T11-33-43-528Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T11-33-43-528Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T11-44-05-900Z.db
Normale Datei
BIN
backups/backup_2025-12-22T11-44-05-900Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T11-44-05-900Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T11-44-05-900Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T12-06-42-448Z.db
Normale Datei
BIN
backups/backup_2025-12-22T12-06-42-448Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T12-06-42-448Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T12-06-42-448Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T12-18-33-252Z.db
Normale Datei
BIN
backups/backup_2025-12-22T12-18-33-252Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T12-18-33-252Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T12-18-33-252Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T12-33-43-793Z.db
Normale Datei
BIN
backups/backup_2025-12-22T12-33-43-793Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T12-33-43-793Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T12-33-43-793Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T12-41-59-725Z.db
Normale Datei
BIN
backups/backup_2025-12-22T12-41-59-725Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T12-41-59-725Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T12-41-59-725Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T19-54-01-136Z.db
Normale Datei
BIN
backups/backup_2025-12-22T19-54-01-136Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T19-54-01-136Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T19-54-01-136Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T20-12-14-820Z.db
Normale Datei
BIN
backups/backup_2025-12-22T20-12-14-820Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T20-12-14-820Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T20-12-14-820Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T20-56-42-001Z.db
Normale Datei
BIN
backups/backup_2025-12-22T20-56-42-001Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T20-56-42-001Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T20-56-42-001Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T21-43-57-007Z.db
Normale Datei
BIN
backups/backup_2025-12-22T21-43-57-007Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T21-43-57-007Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T21-43-57-007Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T22-10-28-996Z.db
Normale Datei
BIN
backups/backup_2025-12-22T22-10-28-996Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T22-10-28-996Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T22-10-28-996Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T22-13-25-791Z.db
Normale Datei
BIN
backups/backup_2025-12-22T22-13-25-791Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T22-13-25-791Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T22-13-25-791Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T22-15-36-577Z.db
Normale Datei
BIN
backups/backup_2025-12-22T22-15-36-577Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T22-15-36-577Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T22-15-36-577Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T22-34-14-857Z.db
Normale Datei
BIN
backups/backup_2025-12-22T22-34-14-857Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-22T22-34-14-857Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-22T22-34-14-857Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T15-51-48-822Z.db
Normale Datei
BIN
backups/backup_2025-12-28T15-51-48-822Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T15-51-48-822Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T15-51-48-822Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T15-58-05-197Z.db
Normale Datei
BIN
backups/backup_2025-12-28T15-58-05-197Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T15-58-05-197Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T15-58-05-197Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-06-29-362Z.db
Normale Datei
BIN
backups/backup_2025-12-28T16-06-29-362Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-06-29-362Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T16-06-29-362Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-17-00-340Z.db
Normale Datei
BIN
backups/backup_2025-12-28T16-17-00-340Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-17-00-340Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T16-17-00-340Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-19-37-475Z.db
Normale Datei
BIN
backups/backup_2025-12-28T16-19-37-475Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-19-37-475Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T16-19-37-475Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-24-54-155Z.db
Normale Datei
BIN
backups/backup_2025-12-28T16-24-54-155Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-24-54-155Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T16-24-54-155Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-34-12-984Z.db
Normale Datei
BIN
backups/backup_2025-12-28T16-34-12-984Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-34-12-984Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T16-34-12-984Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-54-50-274Z.db
Normale Datei
BIN
backups/backup_2025-12-28T16-54-50-274Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T16-54-50-274Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T16-54-50-274Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T19-49-36-012Z.db
Normale Datei
BIN
backups/backup_2025-12-28T19-49-36-012Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T19-49-36-012Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T19-49-36-012Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T20-09-05-908Z.db
Normale Datei
BIN
backups/backup_2025-12-28T20-09-05-908Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T20-09-05-908Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T20-09-05-908Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T20-59-42-468Z.db
Normale Datei
BIN
backups/backup_2025-12-28T20-59-42-468Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T20-59-42-468Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T20-59-42-468Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T21-11-36-076Z.db
Normale Datei
BIN
backups/backup_2025-12-28T21-11-36-076Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T21-11-36-076Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T21-11-36-076Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T21-13-56-595Z.db
Normale Datei
BIN
backups/backup_2025-12-28T21-13-56-595Z.db
Normale Datei
Binäre Datei nicht angezeigt.
BIN
backups/backup_2025-12-28T21-13-56-595Z.db-wal
Normale Datei
BIN
backups/backup_2025-12-28T21-13-56-595Z.db-wal
Normale Datei
Binäre Datei nicht angezeigt.
Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden Mehr anzeigen
In neuem Issue referenzieren
Einen Benutzer sperren