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

536
backend/database.js Normale Datei
Datei anzeigen

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

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

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

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

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

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

@ -0,0 +1,198 @@
/**
* TASKMATE - File Upload
* ======================
* Multer-Konfiguration für Datei-Uploads
*/
const multer = require('multer');
const path = require('path');
const fs = require('fs');
const { v4: uuidv4 } = require('uuid');
const logger = require('../utils/logger');
// Upload-Verzeichnis
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
// Verzeichnis erstellen falls nicht vorhanden
if (!fs.existsSync(UPLOAD_DIR)) {
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
}
// Standard-Werte (Fallback)
let MAX_FILE_SIZE = (parseInt(process.env.MAX_FILE_SIZE_MB) || 15) * 1024 * 1024;
let ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
'application/pdf',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'text/plain', 'text/csv', 'text/markdown',
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
'application/json'
];
/**
* Lädt Upload-Einstellungen aus der Datenbank
*/
function loadUploadSettings() {
try {
// Lazy-Load um zirkuläre Abhängigkeiten zu vermeiden
const { getUploadSettings } = require('../routes/admin');
const settings = getUploadSettings();
if (settings) {
MAX_FILE_SIZE = (settings.maxFileSizeMB || 15) * 1024 * 1024;
// Erlaubte MIME-Types aus den aktiven Kategorien zusammenstellen
const types = [];
if (settings.allowedTypes) {
Object.values(settings.allowedTypes).forEach(category => {
if (category.enabled && Array.isArray(category.types)) {
types.push(...category.types);
}
});
}
if (types.length > 0) {
ALLOWED_MIME_TYPES = types;
}
}
} catch (error) {
// Bei Fehler Standard-Werte beibehalten
logger.warn('Upload-Einstellungen konnten nicht geladen werden, verwende Standards');
}
}
/**
* Aktuelle Einstellungen abrufen (für dynamische Prüfung)
*/
function getCurrentSettings() {
loadUploadSettings();
return { maxFileSize: MAX_FILE_SIZE, allowedMimeTypes: ALLOWED_MIME_TYPES };
}
/**
* Storage-Konfiguration
*/
const storage = multer.diskStorage({
destination: (req, file, cb) => {
// Task-ID aus URL oder Body
const taskId = req.params.taskId || req.body.taskId;
if (taskId) {
// Unterordner pro Task
const taskDir = path.join(UPLOAD_DIR, `task_${taskId}`);
if (!fs.existsSync(taskDir)) {
fs.mkdirSync(taskDir, { recursive: true });
}
cb(null, taskDir);
} else {
cb(null, UPLOAD_DIR);
}
},
filename: (req, file, cb) => {
// Eindeutiger Dateiname mit Original-Extension
const ext = path.extname(file.originalname).toLowerCase();
const uniqueName = `${uuidv4()}${ext}`;
cb(null, uniqueName);
}
});
/**
* Datei-Filter
*/
const fileFilter = (req, file, cb) => {
// Aktuelle Einstellungen laden
const settings = getCurrentSettings();
// MIME-Type prüfen
if (settings.allowedMimeTypes.includes(file.mimetype)) {
cb(null, true);
} else {
logger.warn(`Abgelehnter Upload: ${file.originalname} (${file.mimetype})`);
cb(new Error(`Dateityp nicht erlaubt: ${file.mimetype}`), false);
}
};
/**
* Dynamische Multer-Instanz erstellen
*/
function createUpload() {
const settings = getCurrentSettings();
return multer({
storage,
fileFilter,
limits: {
fileSize: settings.maxFileSize,
files: 10 // Maximal 10 Dateien pro Request
}
});
}
// Standard-Instanz für Rückwärtskompatibilität
const upload = createUpload();
/**
* Datei löschen
*/
function deleteFile(filePath) {
try {
const fullPath = path.join(UPLOAD_DIR, filePath);
if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath);
logger.info(`Datei gelöscht: ${filePath}`);
return true;
}
return false;
} catch (error) {
logger.error(`Fehler beim Löschen: ${filePath}`, { error: error.message });
return false;
}
}
/**
* Dateigröße formatieren
*/
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
/**
* Ist Bild?
*/
function isImage(mimeType) {
return mimeType && mimeType.startsWith('image/');
}
/**
* Datei-Icon basierend auf MIME-Type
*/
function getFileIcon(mimeType) {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType === 'application/pdf') return 'pdf';
if (mimeType.includes('word')) return 'word';
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return 'excel';
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return 'powerpoint';
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('7z')) return 'archive';
if (mimeType.startsWith('text/')) return 'text';
return 'file';
}
module.exports = {
upload,
createUpload,
deleteFile,
formatFileSize,
isImage,
getFileIcon,
getCurrentSettings,
UPLOAD_DIR,
MAX_FILE_SIZE,
ALLOWED_MIME_TYPES
};

Datei anzeigen

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

2021
backend/package-lock.json generiert Normale Datei

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

30
backend/package.json Normale Datei
Datei anzeigen

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Datei anzeigen

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

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

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

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

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

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

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

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

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

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

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

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

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

307
backend/server.js Normale Datei
Datei anzeigen

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

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

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

Datei anzeigen

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

Datei anzeigen

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

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

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

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

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