Initial commit
Dieser Commit ist enthalten in:
536
backend/database.js
Normale Datei
536
backend/database.js
Normale Datei
@ -0,0 +1,536 @@
|
||||
/**
|
||||
* TASKMATE - Datenbank
|
||||
* ====================
|
||||
* SQLite-Datenbank mit better-sqlite3
|
||||
*/
|
||||
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const logger = require('./utils/logger');
|
||||
|
||||
// Datenbank-Pfad
|
||||
const DB_PATH = process.env.DB_PATH || path.join(__dirname, 'data', 'taskmate.db');
|
||||
|
||||
let db = null;
|
||||
|
||||
/**
|
||||
* Datenbank initialisieren
|
||||
*/
|
||||
async function initialize() {
|
||||
try {
|
||||
// Datenbank öffnen/erstellen
|
||||
db = new Database(DB_PATH);
|
||||
|
||||
// WAL-Modus für bessere Performance
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Tabellen erstellen
|
||||
createTables();
|
||||
|
||||
// Standard-Benutzer erstellen (falls nicht vorhanden)
|
||||
await createDefaultUsers();
|
||||
|
||||
logger.info('Datenbank initialisiert');
|
||||
return db;
|
||||
} catch (error) {
|
||||
logger.error('Datenbank-Fehler:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tabellen erstellen
|
||||
*/
|
||||
function createTables() {
|
||||
// Benutzer
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
color TEXT NOT NULL DEFAULT '#00D4FF',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
last_login DATETIME,
|
||||
failed_attempts INTEGER DEFAULT 0,
|
||||
locked_until DATETIME
|
||||
)
|
||||
`);
|
||||
|
||||
// Login-Audit
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS login_audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
ip_address TEXT,
|
||||
success INTEGER NOT NULL,
|
||||
user_agent TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Projekte
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS projects (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
archived INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Spalten
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS columns (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
position INTEGER NOT NULL,
|
||||
color TEXT,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Labels
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS labels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Aufgaben
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
column_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
priority TEXT DEFAULT 'medium',
|
||||
start_date DATE,
|
||||
due_date DATE,
|
||||
assigned_to INTEGER,
|
||||
time_estimate_min INTEGER,
|
||||
depends_on INTEGER,
|
||||
position INTEGER NOT NULL,
|
||||
archived INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (column_id) REFERENCES columns(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (assigned_to) REFERENCES users(id),
|
||||
FOREIGN KEY (depends_on) REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add start_date column if it doesn't exist
|
||||
const columns = db.prepare("PRAGMA table_info(tasks)").all();
|
||||
const hasStartDate = columns.some(col => col.name === 'start_date');
|
||||
if (!hasStartDate) {
|
||||
db.exec('ALTER TABLE tasks ADD COLUMN start_date DATE');
|
||||
logger.info('Migration: start_date Spalte zu tasks hinzugefuegt');
|
||||
}
|
||||
|
||||
// Migration: Add role and permissions columns to users
|
||||
const userColumns = db.prepare("PRAGMA table_info(users)").all();
|
||||
const hasRole = userColumns.some(col => col.name === 'role');
|
||||
if (!hasRole) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'");
|
||||
logger.info('Migration: role Spalte zu users hinzugefuegt');
|
||||
}
|
||||
const hasPermissions = userColumns.some(col => col.name === 'permissions');
|
||||
if (!hasPermissions) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN permissions TEXT DEFAULT '[]'");
|
||||
logger.info('Migration: permissions Spalte zu users hinzugefuegt');
|
||||
}
|
||||
|
||||
// Migration: Add email column to users
|
||||
const hasEmail = userColumns.some(col => col.name === 'email');
|
||||
if (!hasEmail) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN email TEXT");
|
||||
logger.info('Migration: email Spalte zu users hinzugefuegt');
|
||||
}
|
||||
|
||||
// Migration: Add repositories_base_path column to users
|
||||
const hasRepoBasePath = userColumns.some(col => col.name === 'repositories_base_path');
|
||||
if (!hasRepoBasePath) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN repositories_base_path TEXT");
|
||||
logger.info('Migration: repositories_base_path Spalte zu users hinzugefuegt');
|
||||
}
|
||||
|
||||
// Proposals (Vorschlaege)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proposals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_by INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
approved INTEGER DEFAULT 0,
|
||||
approved_by INTEGER,
|
||||
approved_at DATETIME,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id),
|
||||
FOREIGN KEY (approved_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Proposal Votes
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS proposal_votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
proposal_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (proposal_id) REFERENCES proposals(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Unique constraint for proposal votes (one vote per user per proposal)
|
||||
db.exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_proposal_votes_unique
|
||||
ON proposal_votes(proposal_id, user_id)
|
||||
`);
|
||||
|
||||
// Index for proposal votes
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_proposal_votes_proposal
|
||||
ON proposal_votes(proposal_id)
|
||||
`);
|
||||
|
||||
// Migration: Add archived, task_id, and project_id columns to proposals
|
||||
const proposalColumns = db.prepare("PRAGMA table_info(proposals)").all();
|
||||
const hasProposalArchived = proposalColumns.some(col => col.name === 'archived');
|
||||
if (!hasProposalArchived) {
|
||||
db.exec('ALTER TABLE proposals ADD COLUMN archived INTEGER DEFAULT 0');
|
||||
logger.info('Migration: archived Spalte zu proposals hinzugefuegt');
|
||||
}
|
||||
const hasProposalTaskId = proposalColumns.some(col => col.name === 'task_id');
|
||||
if (!hasProposalTaskId) {
|
||||
db.exec('ALTER TABLE proposals ADD COLUMN task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL');
|
||||
logger.info('Migration: task_id Spalte zu proposals hinzugefuegt');
|
||||
}
|
||||
const hasProposalProjectId = proposalColumns.some(col => col.name === 'project_id');
|
||||
if (!hasProposalProjectId) {
|
||||
db.exec('ALTER TABLE proposals ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE CASCADE');
|
||||
logger.info('Migration: project_id Spalte zu proposals hinzugefuegt');
|
||||
}
|
||||
|
||||
// Migration: Add filter_category column to columns
|
||||
const columnColumns = db.prepare("PRAGMA table_info(columns)").all();
|
||||
const hasFilterCategory = columnColumns.some(col => col.name === 'filter_category');
|
||||
if (!hasFilterCategory) {
|
||||
db.exec("ALTER TABLE columns ADD COLUMN filter_category TEXT DEFAULT 'in_progress'");
|
||||
logger.info('Migration: filter_category Spalte zu columns hinzugefuegt');
|
||||
|
||||
// Set default values for existing columns based on position
|
||||
const projects = db.prepare('SELECT id FROM projects').all();
|
||||
for (const project of projects) {
|
||||
const cols = db.prepare('SELECT id, position FROM columns WHERE project_id = ? ORDER BY position').all(project.id);
|
||||
if (cols.length > 0) {
|
||||
// First column = open
|
||||
db.prepare("UPDATE columns SET filter_category = 'open' WHERE id = ?").run(cols[0].id);
|
||||
// Last column = completed
|
||||
if (cols.length > 1) {
|
||||
db.prepare("UPDATE columns SET filter_category = 'completed' WHERE id = ?").run(cols[cols.length - 1].id);
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info('Migration: Standard-Filterkategorien fuer bestehende Spalten gesetzt');
|
||||
}
|
||||
|
||||
// Task-Labels (Verknüpfung)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS task_labels (
|
||||
task_id INTEGER NOT NULL,
|
||||
label_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (task_id, label_id),
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Task-Assignees (Mehrfachzuweisung von Mitarbeitern)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS task_assignees (
|
||||
task_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (task_id, user_id),
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Unteraufgaben
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS subtasks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
completed INTEGER DEFAULT 0,
|
||||
position INTEGER NOT NULL,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Kommentare
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Anhänge
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
filename TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes INTEGER NOT NULL,
|
||||
uploaded_by INTEGER,
|
||||
uploaded_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Links
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
title TEXT,
|
||||
url TEXT NOT NULL,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Aufgaben-Vorlagen
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS task_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
title_template TEXT,
|
||||
description TEXT,
|
||||
priority TEXT,
|
||||
labels TEXT,
|
||||
subtasks TEXT,
|
||||
time_estimate_min INTEGER,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Historie
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id INTEGER NOT NULL,
|
||||
user_id INTEGER NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
field_changed TEXT,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Einstellungen
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
// Anwendungen (Git-Repositories pro Projekt)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS applications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
project_id INTEGER NOT NULL UNIQUE,
|
||||
local_path TEXT NOT NULL,
|
||||
gitea_repo_url TEXT,
|
||||
gitea_repo_owner TEXT,
|
||||
gitea_repo_name TEXT,
|
||||
default_branch TEXT DEFAULT 'main',
|
||||
last_sync DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INTEGER,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Benachrichtigungen (Inbox)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
message TEXT,
|
||||
task_id INTEGER,
|
||||
project_id INTEGER,
|
||||
proposal_id INTEGER,
|
||||
actor_id INTEGER,
|
||||
is_read INTEGER DEFAULT 0,
|
||||
is_persistent INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (proposal_id) REFERENCES proposals(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||||
)
|
||||
`);
|
||||
|
||||
// Indizes für Performance
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_project ON tasks(project_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_column ON tasks(column_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_assigned ON tasks(assigned_to);
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_due_date ON tasks(due_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_subtasks_task ON subtasks(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_task ON comments(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_history_task ON history(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_attachments_task ON attachments(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_links_task ON links(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_labels_task ON task_labels(task_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_task_labels_label ON task_labels(label_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user ON notifications(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_user_read ON notifications(user_id, is_read);
|
||||
CREATE INDEX IF NOT EXISTS idx_notifications_created ON notifications(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_applications_project ON applications(project_id);
|
||||
`);
|
||||
|
||||
logger.info('Datenbank-Tabellen erstellt');
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard-Benutzer erstellen
|
||||
*/
|
||||
async function createDefaultUsers() {
|
||||
const existingUsers = db.prepare('SELECT COUNT(*) as count FROM users').get();
|
||||
|
||||
if (existingUsers.count === 0) {
|
||||
// Benutzer aus Umgebungsvariablen
|
||||
const user1 = {
|
||||
username: process.env.USER1_USERNAME || 'user1',
|
||||
password: process.env.USER1_PASSWORD || 'changeme123',
|
||||
displayName: process.env.USER1_DISPLAYNAME || 'Benutzer 1',
|
||||
color: process.env.USER1_COLOR || '#00D4FF'
|
||||
};
|
||||
|
||||
const user2 = {
|
||||
username: process.env.USER2_USERNAME || 'user2',
|
||||
password: process.env.USER2_PASSWORD || 'changeme456',
|
||||
displayName: process.env.USER2_DISPLAYNAME || 'Benutzer 2',
|
||||
color: process.env.USER2_COLOR || '#FF9500'
|
||||
};
|
||||
|
||||
const insertUser = db.prepare(`
|
||||
INSERT INTO users (username, password_hash, display_name, color, role, permissions)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
// Admin-Benutzer
|
||||
const adminUser = {
|
||||
username: 'admin',
|
||||
password: '!1Data123',
|
||||
displayName: 'Administrator',
|
||||
color: '#8B5CF6'
|
||||
};
|
||||
|
||||
// Passwoerter hashen und Benutzer erstellen
|
||||
const hash1 = await bcrypt.hash(user1.password, 12);
|
||||
const hash2 = await bcrypt.hash(user2.password, 12);
|
||||
const hashAdmin = await bcrypt.hash(adminUser.password, 12);
|
||||
|
||||
insertUser.run(user1.username, hash1, user1.displayName, user1.color, 'user', '[]');
|
||||
insertUser.run(user2.username, hash2, user2.displayName, user2.color, 'user', '[]');
|
||||
insertUser.run(adminUser.username, hashAdmin, adminUser.displayName, adminUser.color, 'admin', '[]');
|
||||
|
||||
logger.info('Standard-Benutzer und Admin erstellt');
|
||||
|
||||
// Standard-Projekt erstellen
|
||||
const projectResult = db.prepare(`
|
||||
INSERT INTO projects (name, description, created_by)
|
||||
VALUES (?, ?, ?)
|
||||
`).run('Mein erstes Projekt', 'Willkommen bei TaskMate!', 1);
|
||||
|
||||
const projectId = projectResult.lastInsertRowid;
|
||||
|
||||
// Standard-Spalten erstellen
|
||||
const insertColumn = db.prepare(`
|
||||
INSERT INTO columns (project_id, name, position, color)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
insertColumn.run(projectId, 'Offen', 0, null);
|
||||
insertColumn.run(projectId, 'In Arbeit', 1, null);
|
||||
insertColumn.run(projectId, 'Erledigt', 2, null);
|
||||
|
||||
// Standard-Labels erstellen
|
||||
const insertLabel = db.prepare(`
|
||||
INSERT INTO labels (project_id, name, color)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
insertLabel.run(projectId, 'Bug', '#DC2626');
|
||||
insertLabel.run(projectId, 'Feature', '#059669');
|
||||
insertLabel.run(projectId, 'Dokumentation', '#3182CE');
|
||||
|
||||
logger.info('Standard-Projekt mit Spalten und Labels erstellt');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Datenbank-Instanz abrufen
|
||||
*/
|
||||
function getDb() {
|
||||
if (!db) {
|
||||
throw new Error('Datenbank nicht initialisiert');
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
/**
|
||||
* Datenbank schließen
|
||||
*/
|
||||
function close() {
|
||||
if (db) {
|
||||
db.close();
|
||||
logger.info('Datenbank geschlossen');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initialize,
|
||||
getDb,
|
||||
close
|
||||
};
|
||||
189
backend/middleware/auth.js
Normale Datei
189
backend/middleware/auth.js
Normale Datei
@ -0,0 +1,189 @@
|
||||
/**
|
||||
* TASKMATE - Auth Middleware
|
||||
* ==========================
|
||||
* JWT-basierte Authentifizierung
|
||||
*/
|
||||
|
||||
const jwt = require('jsonwebtoken');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'UNSICHER_BITTE_AENDERN';
|
||||
const SESSION_TIMEOUT = parseInt(process.env.SESSION_TIMEOUT) || 30; // Minuten
|
||||
|
||||
/**
|
||||
* JWT-Token generieren
|
||||
*/
|
||||
function generateToken(user) {
|
||||
// Permissions parsen falls als String gespeichert
|
||||
let permissions = user.permissions || [];
|
||||
if (typeof permissions === 'string') {
|
||||
try {
|
||||
permissions = JSON.parse(permissions);
|
||||
} catch (e) {
|
||||
permissions = [];
|
||||
}
|
||||
}
|
||||
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
color: user.color,
|
||||
role: user.role || 'user',
|
||||
permissions: permissions
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: `${SESSION_TIMEOUT}m` }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* JWT-Token verifizieren
|
||||
*/
|
||||
function verifyToken(token) {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express Middleware: Token aus Header, Cookie oder Query-Parameter prüfen
|
||||
*/
|
||||
function authenticateToken(req, res, next) {
|
||||
// Token aus Authorization Header, Cookie oder Query-Parameter (für img src etc.)
|
||||
let token = null;
|
||||
|
||||
const authHeader = req.headers['authorization'];
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
token = authHeader.substring(7);
|
||||
} else if (req.cookies && req.cookies.token) {
|
||||
token = req.cookies.token;
|
||||
} else if (req.query && req.query.token) {
|
||||
// Token aus Query-Parameter (für Ressourcen die in img/video tags geladen werden)
|
||||
token = req.query.token;
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
const user = verifyToken(token);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Token ungültig oder abgelaufen' });
|
||||
}
|
||||
|
||||
// User-Info an Request anhängen
|
||||
req.user = user;
|
||||
|
||||
// Token-Refresh: Wenn Token bald ablaeuft, neuen ausstellen
|
||||
const tokenExp = user.exp * 1000; // exp ist in Sekunden
|
||||
const now = Date.now();
|
||||
const refreshThreshold = 5 * 60 * 1000; // 5 Minuten vor Ablauf
|
||||
|
||||
if (tokenExp - now < refreshThreshold) {
|
||||
const newToken = generateToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.displayName,
|
||||
color: user.color,
|
||||
role: user.role,
|
||||
permissions: user.permissions
|
||||
});
|
||||
res.setHeader('X-New-Token', newToken);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Nur Admins erlauben
|
||||
*/
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
if (req.user.role !== 'admin') {
|
||||
return res.status(403).json({ error: 'Keine Admin-Berechtigung' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware: Nur regulaere User erlauben (blockiert Admins)
|
||||
*/
|
||||
function requireRegularUser(req, res, next) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
if (req.user.role === 'admin') {
|
||||
return res.status(403).json({ error: 'Admin hat keinen Zugang zur regulaeren App' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Middleware-Factory: Bestimmte Berechtigung pruefen
|
||||
*/
|
||||
function checkPermission(permission) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Nicht authentifiziert' });
|
||||
}
|
||||
|
||||
const permissions = req.user.permissions || [];
|
||||
if (!permissions.includes(permission)) {
|
||||
return res.status(403).json({ error: `Berechtigung "${permission}" fehlt` });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Socket.io Middleware: Token prüfen
|
||||
*/
|
||||
function authenticateSocket(socket, next) {
|
||||
const token = socket.handshake.auth.token ||
|
||||
socket.handshake.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return next(new Error('Nicht authentifiziert'));
|
||||
}
|
||||
|
||||
const user = verifyToken(token);
|
||||
if (!user) {
|
||||
return next(new Error('Token ungültig oder abgelaufen'));
|
||||
}
|
||||
|
||||
// User-Info an Socket anhängen
|
||||
socket.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF-Token generieren (für Forms)
|
||||
*/
|
||||
function generateCsrfToken() {
|
||||
const { randomBytes } = require('crypto');
|
||||
return randomBytes(32).toString('hex');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateToken,
|
||||
verifyToken,
|
||||
authenticateToken,
|
||||
authenticateSocket,
|
||||
generateCsrfToken,
|
||||
requireAdmin,
|
||||
requireRegularUser,
|
||||
checkPermission,
|
||||
JWT_SECRET,
|
||||
SESSION_TIMEOUT
|
||||
};
|
||||
125
backend/middleware/csrf.js
Normale Datei
125
backend/middleware/csrf.js
Normale Datei
@ -0,0 +1,125 @@
|
||||
/**
|
||||
* TASKMATE - CSRF Schutz
|
||||
* ======================
|
||||
* Cross-Site Request Forgery Schutz
|
||||
*
|
||||
* Vereinfachtes System: Token wird beim Login generiert und bleibt
|
||||
* für die gesamte Sitzung gültig (24 Stunden).
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// CSRF-Tokens speichern (in-memory, pro User)
|
||||
const csrfTokens = new Map();
|
||||
|
||||
// Token-Gültigkeit: 24 Stunden
|
||||
const TOKEN_VALIDITY = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* CSRF-Token generieren
|
||||
*/
|
||||
function generateToken(userId) {
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expires = Date.now() + TOKEN_VALIDITY;
|
||||
|
||||
csrfTokens.set(userId, { token, expires });
|
||||
cleanupExpiredTokens();
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF-Token validieren
|
||||
*/
|
||||
function validateToken(userId, token) {
|
||||
const stored = csrfTokens.get(userId);
|
||||
|
||||
if (!stored) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Date.now() > stored.expires) {
|
||||
csrfTokens.delete(userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
return stored.token === token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Abgelaufene Tokens aufräumen
|
||||
*/
|
||||
function cleanupExpiredTokens() {
|
||||
const now = Date.now();
|
||||
for (const [userId, data] of csrfTokens.entries()) {
|
||||
if (now > data.expires) {
|
||||
csrfTokens.delete(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regelmäßig aufräumen (alle 30 Minuten)
|
||||
setInterval(cleanupExpiredTokens, 30 * 60 * 1000);
|
||||
|
||||
/**
|
||||
* Express Middleware
|
||||
*/
|
||||
function csrfProtection(req, res, next) {
|
||||
// GET-Anfragen sind sicher (lesen nur)
|
||||
if (req.method === 'GET') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// User muss authentifiziert sein
|
||||
if (!req.user) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const userId = req.user.id;
|
||||
|
||||
// Token aus Header oder Body
|
||||
const token = req.headers['x-csrf-token'] || req.body?._csrf;
|
||||
|
||||
// Wenn kein Token vom Client gesendet oder kein Token auf Server gespeichert
|
||||
if (!token || !csrfTokens.has(userId)) {
|
||||
const newToken = generateToken(userId);
|
||||
logger.info(`CSRF: Token missing or not stored for user ${userId}, generated new token`);
|
||||
return res.status(403).json({
|
||||
error: 'CSRF-Token fehlt oder abgelaufen',
|
||||
csrfToken: newToken,
|
||||
code: 'CSRF_ERROR'
|
||||
});
|
||||
}
|
||||
|
||||
// Token validieren
|
||||
if (!validateToken(userId, token)) {
|
||||
const newToken = generateToken(userId);
|
||||
logger.info(`CSRF: Token mismatch for user ${userId}`);
|
||||
return res.status(403).json({
|
||||
error: 'Ungültiges CSRF-Token',
|
||||
csrfToken: newToken,
|
||||
code: 'CSRF_ERROR'
|
||||
});
|
||||
}
|
||||
|
||||
// Token ist gültig - KEIN neuer Token generiert (bleibt für die Sitzung gleich)
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuellen Token für User abrufen (oder neuen generieren)
|
||||
*/
|
||||
function getTokenForUser(userId) {
|
||||
const stored = csrfTokens.get(userId);
|
||||
|
||||
if (stored && Date.now() < stored.expires) {
|
||||
return stored.token;
|
||||
}
|
||||
|
||||
return generateToken(userId);
|
||||
}
|
||||
|
||||
module.exports = csrfProtection;
|
||||
module.exports.generateToken = generateToken;
|
||||
module.exports.getTokenForUser = getTokenForUser;
|
||||
198
backend/middleware/upload.js
Normale Datei
198
backend/middleware/upload.js
Normale Datei
@ -0,0 +1,198 @@
|
||||
/**
|
||||
* TASKMATE - File Upload
|
||||
* ======================
|
||||
* Multer-Konfiguration für Datei-Uploads
|
||||
*/
|
||||
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Upload-Verzeichnis
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
|
||||
|
||||
// Verzeichnis erstellen falls nicht vorhanden
|
||||
if (!fs.existsSync(UPLOAD_DIR)) {
|
||||
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Standard-Werte (Fallback)
|
||||
let MAX_FILE_SIZE = (parseInt(process.env.MAX_FILE_SIZE_MB) || 15) * 1024 * 1024;
|
||||
let ALLOWED_MIME_TYPES = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml',
|
||||
'application/pdf',
|
||||
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint', 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'text/plain', 'text/csv', 'text/markdown',
|
||||
'application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed',
|
||||
'application/json'
|
||||
];
|
||||
|
||||
/**
|
||||
* Lädt Upload-Einstellungen aus der Datenbank
|
||||
*/
|
||||
function loadUploadSettings() {
|
||||
try {
|
||||
// Lazy-Load um zirkuläre Abhängigkeiten zu vermeiden
|
||||
const { getUploadSettings } = require('../routes/admin');
|
||||
const settings = getUploadSettings();
|
||||
|
||||
if (settings) {
|
||||
MAX_FILE_SIZE = (settings.maxFileSizeMB || 15) * 1024 * 1024;
|
||||
|
||||
// Erlaubte MIME-Types aus den aktiven Kategorien zusammenstellen
|
||||
const types = [];
|
||||
if (settings.allowedTypes) {
|
||||
Object.values(settings.allowedTypes).forEach(category => {
|
||||
if (category.enabled && Array.isArray(category.types)) {
|
||||
types.push(...category.types);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (types.length > 0) {
|
||||
ALLOWED_MIME_TYPES = types;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Bei Fehler Standard-Werte beibehalten
|
||||
logger.warn('Upload-Einstellungen konnten nicht geladen werden, verwende Standards');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktuelle Einstellungen abrufen (für dynamische Prüfung)
|
||||
*/
|
||||
function getCurrentSettings() {
|
||||
loadUploadSettings();
|
||||
return { maxFileSize: MAX_FILE_SIZE, allowedMimeTypes: ALLOWED_MIME_TYPES };
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage-Konfiguration
|
||||
*/
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
// Task-ID aus URL oder Body
|
||||
const taskId = req.params.taskId || req.body.taskId;
|
||||
|
||||
if (taskId) {
|
||||
// Unterordner pro Task
|
||||
const taskDir = path.join(UPLOAD_DIR, `task_${taskId}`);
|
||||
if (!fs.existsSync(taskDir)) {
|
||||
fs.mkdirSync(taskDir, { recursive: true });
|
||||
}
|
||||
cb(null, taskDir);
|
||||
} else {
|
||||
cb(null, UPLOAD_DIR);
|
||||
}
|
||||
},
|
||||
|
||||
filename: (req, file, cb) => {
|
||||
// Eindeutiger Dateiname mit Original-Extension
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const uniqueName = `${uuidv4()}${ext}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Datei-Filter
|
||||
*/
|
||||
const fileFilter = (req, file, cb) => {
|
||||
// Aktuelle Einstellungen laden
|
||||
const settings = getCurrentSettings();
|
||||
|
||||
// MIME-Type prüfen
|
||||
if (settings.allowedMimeTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
logger.warn(`Abgelehnter Upload: ${file.originalname} (${file.mimetype})`);
|
||||
cb(new Error(`Dateityp nicht erlaubt: ${file.mimetype}`), false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Dynamische Multer-Instanz erstellen
|
||||
*/
|
||||
function createUpload() {
|
||||
const settings = getCurrentSettings();
|
||||
return multer({
|
||||
storage,
|
||||
fileFilter,
|
||||
limits: {
|
||||
fileSize: settings.maxFileSize,
|
||||
files: 10 // Maximal 10 Dateien pro Request
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Standard-Instanz für Rückwärtskompatibilität
|
||||
const upload = createUpload();
|
||||
|
||||
/**
|
||||
* Datei löschen
|
||||
*/
|
||||
function deleteFile(filePath) {
|
||||
try {
|
||||
const fullPath = path.join(UPLOAD_DIR, filePath);
|
||||
if (fs.existsSync(fullPath)) {
|
||||
fs.unlinkSync(fullPath);
|
||||
logger.info(`Datei gelöscht: ${filePath}`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Löschen: ${filePath}`, { error: error.message });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dateigröße formatieren
|
||||
*/
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist Bild?
|
||||
*/
|
||||
function isImage(mimeType) {
|
||||
return mimeType && mimeType.startsWith('image/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Datei-Icon basierend auf MIME-Type
|
||||
*/
|
||||
function getFileIcon(mimeType) {
|
||||
if (mimeType.startsWith('image/')) return 'image';
|
||||
if (mimeType === 'application/pdf') return 'pdf';
|
||||
if (mimeType.includes('word')) return 'word';
|
||||
if (mimeType.includes('excel') || mimeType.includes('spreadsheet')) return 'excel';
|
||||
if (mimeType.includes('powerpoint') || mimeType.includes('presentation')) return 'powerpoint';
|
||||
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('7z')) return 'archive';
|
||||
if (mimeType.startsWith('text/')) return 'text';
|
||||
return 'file';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
upload,
|
||||
createUpload,
|
||||
deleteFile,
|
||||
formatFileSize,
|
||||
isImage,
|
||||
getFileIcon,
|
||||
getCurrentSettings,
|
||||
UPLOAD_DIR,
|
||||
MAX_FILE_SIZE,
|
||||
ALLOWED_MIME_TYPES
|
||||
};
|
||||
249
backend/middleware/validation.js
Normale Datei
249
backend/middleware/validation.js
Normale Datei
@ -0,0 +1,249 @@
|
||||
/**
|
||||
* TASKMATE - Input Validierung
|
||||
* ============================
|
||||
* Schutz vor SQL-Injection und XSS
|
||||
*/
|
||||
|
||||
const sanitizeHtml = require('sanitize-html');
|
||||
|
||||
/**
|
||||
* HTML-Tags entfernen (für reine Text-Felder)
|
||||
*/
|
||||
function stripHtml(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
return sanitizeHtml(input, {
|
||||
allowedTags: [],
|
||||
allowedAttributes: {}
|
||||
}).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown-sichere Bereinigung (erlaubt bestimmte Tags)
|
||||
*/
|
||||
function sanitizeMarkdown(input) {
|
||||
if (typeof input !== 'string') return input;
|
||||
return sanitizeHtml(input, {
|
||||
allowedTags: [
|
||||
'p', 'br', 'strong', 'em', 'u', 's', 'code', 'pre',
|
||||
'ul', 'ol', 'li', 'blockquote', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6'
|
||||
],
|
||||
allowedAttributes: {
|
||||
'a': ['href', 'title', 'target', 'rel']
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
transformTags: {
|
||||
'a': (tagName, attribs) => {
|
||||
return {
|
||||
tagName: 'a',
|
||||
attribs: {
|
||||
...attribs,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Objekt rekursiv bereinigen
|
||||
*/
|
||||
function sanitizeObject(obj, options = {}) {
|
||||
if (obj === null || obj === undefined) return obj;
|
||||
|
||||
if (typeof obj === 'string') {
|
||||
return options.allowHtml ? sanitizeMarkdown(obj) : stripHtml(obj);
|
||||
}
|
||||
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map(item => sanitizeObject(item, options));
|
||||
}
|
||||
|
||||
if (typeof obj === 'object') {
|
||||
const sanitized = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Bestimmte Felder dürfen Markdown enthalten
|
||||
const allowHtml = ['description', 'content'].includes(key);
|
||||
sanitized[key] = sanitizeObject(value, { allowHtml });
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validierungsfunktionen
|
||||
*/
|
||||
const validators = {
|
||||
/**
|
||||
* Pflichtfeld prüfen
|
||||
*/
|
||||
required: (value, fieldName) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return `${fieldName} ist erforderlich`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Minimale Länge prüfen
|
||||
*/
|
||||
minLength: (value, min, fieldName) => {
|
||||
if (typeof value === 'string' && value.length < min) {
|
||||
return `${fieldName} muss mindestens ${min} Zeichen haben`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Maximale Länge prüfen
|
||||
*/
|
||||
maxLength: (value, max, fieldName) => {
|
||||
if (typeof value === 'string' && value.length > max) {
|
||||
return `${fieldName} darf maximal ${max} Zeichen haben`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* E-Mail-Format prüfen
|
||||
*/
|
||||
email: (value, fieldName) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (value && !emailRegex.test(value)) {
|
||||
return `${fieldName} muss eine gültige E-Mail-Adresse sein`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* URL-Format prüfen
|
||||
*/
|
||||
url: (value, fieldName) => {
|
||||
try {
|
||||
if (value) {
|
||||
new URL(value);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return `${fieldName} muss eine gültige URL sein`;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Integer prüfen
|
||||
*/
|
||||
integer: (value, fieldName) => {
|
||||
if (value !== undefined && value !== null && !Number.isInteger(Number(value))) {
|
||||
return `${fieldName} muss eine ganze Zahl sein`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Positiver Integer prüfen
|
||||
*/
|
||||
positiveInteger: (value, fieldName) => {
|
||||
const num = Number(value);
|
||||
if (value !== undefined && value !== null && (!Number.isInteger(num) || num < 0)) {
|
||||
return `${fieldName} muss eine positive ganze Zahl sein`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Datum prüfen (YYYY-MM-DD)
|
||||
*/
|
||||
date: (value, fieldName) => {
|
||||
if (value) {
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(value)) {
|
||||
return `${fieldName} muss im Format YYYY-MM-DD sein`;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (isNaN(date.getTime())) {
|
||||
return `${fieldName} ist kein gültiges Datum`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Enum-Wert prüfen
|
||||
*/
|
||||
enum: (value, allowedValues, fieldName) => {
|
||||
if (value && !allowedValues.includes(value)) {
|
||||
return `${fieldName} muss einer der folgenden Werte sein: ${allowedValues.join(', ')}`;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Hex-Farbe prüfen
|
||||
*/
|
||||
hexColor: (value, fieldName) => {
|
||||
if (value) {
|
||||
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
|
||||
if (!hexRegex.test(value)) {
|
||||
return `${fieldName} muss eine gültige Hex-Farbe sein (z.B. #FF0000)`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Passwort-Richtlinien prüfen
|
||||
*/
|
||||
function validatePassword(password) {
|
||||
const errors = [];
|
||||
const minLength = 8;
|
||||
|
||||
if (!password || password.length < minLength) {
|
||||
errors.push(`Passwort muss mindestens ${minLength} Zeichen haben`);
|
||||
}
|
||||
|
||||
if (password && !/[a-z]/.test(password)) {
|
||||
errors.push('Passwort muss mindestens einen Kleinbuchstaben enthalten');
|
||||
}
|
||||
|
||||
if (password && !/[A-Z]/.test(password)) {
|
||||
errors.push('Passwort muss mindestens einen Großbuchstaben enthalten');
|
||||
}
|
||||
|
||||
if (password && !/[0-9]/.test(password)) {
|
||||
errors.push('Passwort muss mindestens eine Zahl enthalten');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express Middleware: Request-Body bereinigen
|
||||
*/
|
||||
function sanitizeMiddleware(req, res, next) {
|
||||
if (req.body && typeof req.body === 'object') {
|
||||
req.body = sanitizeObject(req.body);
|
||||
}
|
||||
|
||||
if (req.query && typeof req.query === 'object') {
|
||||
req.query = sanitizeObject(req.query);
|
||||
}
|
||||
|
||||
if (req.params && typeof req.params === 'object') {
|
||||
req.params = sanitizeObject(req.params);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
stripHtml,
|
||||
sanitizeMarkdown,
|
||||
sanitizeObject,
|
||||
validators,
|
||||
validatePassword,
|
||||
sanitizeMiddleware
|
||||
};
|
||||
2021
backend/package-lock.json
generiert
Normale Datei
2021
backend/package-lock.json
generiert
Normale Datei
Datei-Diff unterdrückt, da er zu groß ist
Diff laden
30
backend/package.json
Normale Datei
30
backend/package.json
Normale Datei
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "taskmate",
|
||||
"version": "1.0.0",
|
||||
"description": "TaskMate - Aufgaben einfach verwalten",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"socket.io": "^4.7.2",
|
||||
"better-sqlite3": "^9.2.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"uuid": "^9.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"express-rate-limiter": "^1.3.1",
|
||||
"sanitize-html": "^2.11.0",
|
||||
"marked": "^11.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"author": "IntelSight",
|
||||
"license": "PROPRIETARY"
|
||||
}
|
||||
409
backend/routes/admin.js
Normale Datei
409
backend/routes/admin.js
Normale Datei
@ -0,0 +1,409 @@
|
||||
/**
|
||||
* TASKMATE - Admin Routes
|
||||
* =======================
|
||||
* API-Endpunkte für Benutzerverwaltung
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Standard-Upload-Einstellungen
|
||||
*/
|
||||
const DEFAULT_UPLOAD_SETTINGS = {
|
||||
maxFileSizeMB: 15,
|
||||
allowedTypes: {
|
||||
images: {
|
||||
enabled: true,
|
||||
types: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']
|
||||
},
|
||||
documents: {
|
||||
enabled: true,
|
||||
types: ['application/pdf']
|
||||
},
|
||||
office: {
|
||||
enabled: true,
|
||||
types: [
|
||||
'application/msword',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.ms-excel',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'application/vnd.ms-powerpoint',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation'
|
||||
]
|
||||
},
|
||||
text: {
|
||||
enabled: true,
|
||||
types: ['text/plain', 'text/csv', 'text/markdown']
|
||||
},
|
||||
archives: {
|
||||
enabled: true,
|
||||
types: ['application/zip', 'application/x-rar-compressed', 'application/x-7z-compressed']
|
||||
},
|
||||
data: {
|
||||
enabled: true,
|
||||
types: ['application/json']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Alle Admin-Routes erfordern Authentifizierung und Admin-Rolle
|
||||
router.use(authenticateToken);
|
||||
router.use(requireAdmin);
|
||||
|
||||
/**
|
||||
* GET /api/admin/users - Alle Benutzer abrufen
|
||||
*/
|
||||
router.get('/users', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const users = db.prepare(`
|
||||
SELECT id, username, display_name, color, role, permissions, email,
|
||||
created_at, last_login, failed_attempts, locked_until
|
||||
FROM users
|
||||
ORDER BY id
|
||||
`).all();
|
||||
|
||||
// Permissions parsen
|
||||
const parsedUsers = users.map(user => ({
|
||||
...user,
|
||||
permissions: JSON.parse(user.permissions || '[]')
|
||||
}));
|
||||
|
||||
res.json(parsedUsers);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Benutzer:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Benutzer' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/users - Neuen Benutzer anlegen
|
||||
*/
|
||||
router.post('/users', async (req, res) => {
|
||||
try {
|
||||
const { username, password, displayName, email, role, permissions } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (!username || !password || !displayName || !email) {
|
||||
return res.status(400).json({ error: 'Kürzel, Passwort, Anzeigename und E-Mail erforderlich' });
|
||||
}
|
||||
|
||||
// E-Mail-Validierung
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' });
|
||||
}
|
||||
|
||||
// Kürzel muss genau 2 Buchstaben sein
|
||||
const usernameUpper = username.toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(usernameUpper)) {
|
||||
return res.status(400).json({ error: 'Kürzel muss genau 2 Buchstaben sein (z.B. HG)' });
|
||||
}
|
||||
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prüfen ob Kürzel bereits existiert
|
||||
const existing = db.prepare('SELECT id FROM users WHERE username = ?').get(usernameUpper);
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Kürzel bereits vergeben' });
|
||||
}
|
||||
|
||||
// Prüfen ob E-Mail bereits existiert
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ?').get(email.toLowerCase());
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({ error: 'E-Mail bereits vergeben' });
|
||||
}
|
||||
|
||||
// Passwort hashen
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
|
||||
// Standardfarbe Grau
|
||||
const defaultColor = '#808080';
|
||||
|
||||
// Benutzer erstellen
|
||||
const result = db.prepare(`
|
||||
INSERT INTO users (username, password_hash, display_name, color, role, permissions, email)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
usernameUpper,
|
||||
passwordHash,
|
||||
displayName,
|
||||
defaultColor,
|
||||
role || 'user',
|
||||
JSON.stringify(permissions || []),
|
||||
email.toLowerCase()
|
||||
);
|
||||
|
||||
logger.info(`Admin ${req.user.username} hat Benutzer ${usernameUpper} erstellt`);
|
||||
|
||||
res.status(201).json({
|
||||
id: result.lastInsertRowid,
|
||||
username: usernameUpper,
|
||||
displayName,
|
||||
email: email.toLowerCase(),
|
||||
color: defaultColor,
|
||||
role: role || 'user',
|
||||
permissions: permissions || []
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Benutzers:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Erstellen des Benutzers' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/users/:id - Benutzer bearbeiten
|
||||
*/
|
||||
router.put('/users/:id', async (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
const { displayName, color, role, permissions, password, unlockAccount, email } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Benutzer prüfen
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
|
||||
// Verhindern, dass der einzige Admin seine Rolle ändert
|
||||
if (user.role === 'admin' && role !== 'admin') {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get();
|
||||
if (adminCount.count <= 1) {
|
||||
return res.status(400).json({ error: 'Mindestens ein Admin muss existieren' });
|
||||
}
|
||||
}
|
||||
|
||||
// Update-Felder sammeln
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
if (displayName !== undefined) {
|
||||
updates.push('display_name = ?');
|
||||
params.push(displayName);
|
||||
}
|
||||
|
||||
if (color !== undefined) {
|
||||
updates.push('color = ?');
|
||||
params.push(color);
|
||||
}
|
||||
|
||||
if (role !== undefined) {
|
||||
updates.push('role = ?');
|
||||
params.push(role);
|
||||
}
|
||||
|
||||
if (permissions !== undefined) {
|
||||
updates.push('permissions = ?');
|
||||
params.push(JSON.stringify(permissions));
|
||||
}
|
||||
|
||||
if (password) {
|
||||
if (password.length < 8) {
|
||||
return res.status(400).json({ error: 'Passwort muss mindestens 8 Zeichen haben' });
|
||||
}
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
updates.push('password_hash = ?');
|
||||
params.push(passwordHash);
|
||||
}
|
||||
|
||||
if (unlockAccount) {
|
||||
updates.push('failed_attempts = 0');
|
||||
updates.push('locked_until = NULL');
|
||||
}
|
||||
|
||||
if (email !== undefined) {
|
||||
// E-Mail-Validierung
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return res.status(400).json({ error: 'Ungültige E-Mail-Adresse' });
|
||||
}
|
||||
// Prüfen ob E-Mail bereits von anderem Benutzer verwendet wird
|
||||
const existingEmail = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?').get(email.toLowerCase(), userId);
|
||||
if (existingEmail) {
|
||||
return res.status(400).json({ error: 'E-Mail bereits vergeben' });
|
||||
}
|
||||
updates.push('email = ?');
|
||||
params.push(email.toLowerCase());
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine Änderungen angegeben' });
|
||||
}
|
||||
|
||||
params.push(userId);
|
||||
db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
|
||||
|
||||
logger.info(`Admin ${req.user.username} hat Benutzer ${user.username} bearbeitet`);
|
||||
|
||||
// Aktualisierten Benutzer zurueckgeben
|
||||
const updatedUser = db.prepare(`
|
||||
SELECT id, username, display_name, color, role, permissions, email,
|
||||
created_at, last_login, failed_attempts, locked_until
|
||||
FROM users WHERE id = ?
|
||||
`).get(userId);
|
||||
|
||||
res.json({
|
||||
...updatedUser,
|
||||
permissions: JSON.parse(updatedUser.permissions || '[]')
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Bearbeiten des Benutzers:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Bearbeiten des Benutzers' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/admin/users/:id - Benutzer löschen
|
||||
*/
|
||||
router.delete('/users/:id', (req, res) => {
|
||||
try {
|
||||
const userId = parseInt(req.params.id);
|
||||
const db = getDb();
|
||||
|
||||
// Benutzer prüfen
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
|
||||
// Verhindern, dass der letzte Admin gelöscht wird
|
||||
if (user.role === 'admin') {
|
||||
const adminCount = db.prepare("SELECT COUNT(*) as count FROM users WHERE role = 'admin'").get();
|
||||
if (adminCount.count <= 1) {
|
||||
return res.status(400).json({ error: 'Der letzte Admin kann nicht gelöscht werden' });
|
||||
}
|
||||
}
|
||||
|
||||
// Verhindern, dass man sich selbst löscht
|
||||
if (userId === req.user.id) {
|
||||
return res.status(400).json({ error: 'Sie können sich nicht selbst löschen' });
|
||||
}
|
||||
|
||||
// Alle Referenzen auf den Benutzer auf NULL setzen oder löschen
|
||||
// Tasks
|
||||
db.prepare('UPDATE tasks SET assigned_to = NULL WHERE assigned_to = ?').run(userId);
|
||||
db.prepare('UPDATE tasks SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
|
||||
// Kommentare
|
||||
db.prepare('UPDATE comments SET user_id = NULL WHERE user_id = ?').run(userId);
|
||||
|
||||
// Historie
|
||||
db.prepare('UPDATE history SET user_id = NULL WHERE user_id = ?').run(userId);
|
||||
|
||||
// Vorschläge
|
||||
db.prepare('UPDATE proposals SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
db.prepare('UPDATE proposals SET approved_by = NULL WHERE approved_by = ?').run(userId);
|
||||
|
||||
// Projekte
|
||||
db.prepare('UPDATE projects SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
|
||||
// Anhänge
|
||||
db.prepare('UPDATE attachments SET uploaded_by = NULL WHERE uploaded_by = ?').run(userId);
|
||||
|
||||
// Links
|
||||
db.prepare('UPDATE links SET created_by = NULL WHERE created_by = ?').run(userId);
|
||||
|
||||
// Login-Audit (kann gelöscht werden)
|
||||
db.prepare('DELETE FROM login_audit WHERE user_id = ?').run(userId);
|
||||
|
||||
// Votes des Benutzers löschen
|
||||
db.prepare('DELETE FROM proposal_votes WHERE user_id = ?').run(userId);
|
||||
|
||||
// Benutzer löschen
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(userId);
|
||||
|
||||
logger.info(`Admin ${req.user.username} hat Benutzer ${user.username} gelöscht`);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Benutzers:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen des Benutzers' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/admin/upload-settings - Upload-Einstellungen abrufen
|
||||
*/
|
||||
router.get('/upload-settings', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings');
|
||||
|
||||
if (setting) {
|
||||
const settings = JSON.parse(setting.value);
|
||||
res.json(settings);
|
||||
} else {
|
||||
// Standard-Einstellungen zurückgeben und speichern
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('upload_settings', JSON.stringify(DEFAULT_UPLOAD_SETTINGS));
|
||||
res.json(DEFAULT_UPLOAD_SETTINGS);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Upload-Einstellungen:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Upload-Einstellungen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/admin/upload-settings - Upload-Einstellungen speichern
|
||||
*/
|
||||
router.put('/upload-settings', (req, res) => {
|
||||
try {
|
||||
const { maxFileSizeMB, allowedTypes } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (typeof maxFileSizeMB !== 'number' || maxFileSizeMB < 1 || maxFileSizeMB > 100) {
|
||||
return res.status(400).json({ error: 'Maximale Dateigröße muss zwischen 1 und 100 MB liegen' });
|
||||
}
|
||||
|
||||
if (!allowedTypes || typeof allowedTypes !== 'object') {
|
||||
return res.status(400).json({ error: 'Ungültige Dateityp-Konfiguration' });
|
||||
}
|
||||
|
||||
const settings = { maxFileSizeMB, allowedTypes };
|
||||
|
||||
const db = getDb();
|
||||
db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)')
|
||||
.run('upload_settings', JSON.stringify(settings));
|
||||
|
||||
logger.info(`Admin ${req.user.username} hat Upload-Einstellungen geändert`);
|
||||
|
||||
res.json(settings);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Speichern der Upload-Einstellungen:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Speichern der Upload-Einstellungen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Hilfsfunktion zum Abrufen der aktuellen Upload-Einstellungen
|
||||
*/
|
||||
function getUploadSettings() {
|
||||
try {
|
||||
const db = getDb();
|
||||
const setting = db.prepare('SELECT value FROM settings WHERE key = ?').get('upload_settings');
|
||||
|
||||
if (setting) {
|
||||
return JSON.parse(setting.value);
|
||||
}
|
||||
return DEFAULT_UPLOAD_SETTINGS;
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Upload-Einstellungen:', error);
|
||||
return DEFAULT_UPLOAD_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
module.exports.getUploadSettings = getUploadSettings;
|
||||
module.exports.DEFAULT_UPLOAD_SETTINGS = DEFAULT_UPLOAD_SETTINGS;
|
||||
212
backend/routes/applications.js
Normale Datei
212
backend/routes/applications.js
Normale Datei
@ -0,0 +1,212 @@
|
||||
/**
|
||||
* TASKMATE - Applications Route
|
||||
* ==============================
|
||||
* API-Endpoints für Anwendungs-/Repository-Verwaltung
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const gitService = require('../services/gitService');
|
||||
|
||||
/**
|
||||
* GET /api/applications/:projectId
|
||||
* Anwendungs-Konfiguration für ein Projekt abrufen
|
||||
*/
|
||||
router.get('/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const application = db.prepare(`
|
||||
SELECT a.*, p.name as project_name
|
||||
FROM applications a
|
||||
JOIN projects p ON a.project_id = p.id
|
||||
WHERE a.project_id = ?
|
||||
`).get(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.json({
|
||||
configured: false,
|
||||
projectId: parseInt(projectId)
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfe ob das Repository existiert und erreichbar ist
|
||||
const isRepo = gitService.isGitRepository(application.local_path);
|
||||
const isAccessible = gitService.isPathAccessible(application.local_path);
|
||||
|
||||
res.json({
|
||||
configured: true,
|
||||
...application,
|
||||
isRepository: isRepo,
|
||||
isAccessible
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Anwendung:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/applications
|
||||
* Anwendung für ein Projekt erstellen oder aktualisieren
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { projectId, localPath, giteaRepoUrl, giteaRepoOwner, giteaRepoName, defaultBranch } = req.body;
|
||||
const userId = req.user.id;
|
||||
const db = getDb();
|
||||
|
||||
if (!projectId || !localPath) {
|
||||
return res.status(400).json({ error: 'projectId und localPath sind erforderlich' });
|
||||
}
|
||||
|
||||
// Prüfe ob der Pfad erreichbar ist
|
||||
if (!gitService.isPathAccessible(localPath)) {
|
||||
return res.status(400).json({
|
||||
error: 'Pfad nicht erreichbar. Stelle sicher, dass das Laufwerk in Docker gemountet ist.',
|
||||
hint: 'Gemountete Laufwerke: C:, D:, E:'
|
||||
});
|
||||
}
|
||||
|
||||
// Prüfe ob bereits eine Anwendung für dieses Projekt existiert
|
||||
const existing = db.prepare('SELECT id FROM applications WHERE project_id = ?').get(projectId);
|
||||
|
||||
if (existing) {
|
||||
// Update
|
||||
db.prepare(`
|
||||
UPDATE applications SET
|
||||
local_path = ?,
|
||||
gitea_repo_url = ?,
|
||||
gitea_repo_owner = ?,
|
||||
gitea_repo_name = ?,
|
||||
default_branch = ?
|
||||
WHERE project_id = ?
|
||||
`).run(localPath, giteaRepoUrl || null, giteaRepoOwner || null, giteaRepoName || null, defaultBranch || 'main', projectId);
|
||||
|
||||
logger.info(`Anwendung aktualisiert für Projekt ${projectId}`);
|
||||
} else {
|
||||
// Insert
|
||||
db.prepare(`
|
||||
INSERT INTO applications (project_id, local_path, gitea_repo_url, gitea_repo_owner, gitea_repo_name, default_branch, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(projectId, localPath, giteaRepoUrl || null, giteaRepoOwner || null, giteaRepoName || null, defaultBranch || 'main', userId);
|
||||
|
||||
logger.info(`Anwendung erstellt für Projekt ${projectId}`);
|
||||
}
|
||||
|
||||
// Anwendung zurückgeben
|
||||
const application = db.prepare(`
|
||||
SELECT a.*, p.name as project_name
|
||||
FROM applications a
|
||||
JOIN projects p ON a.project_id = p.id
|
||||
WHERE a.project_id = ?
|
||||
`).get(projectId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
application
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Speichern der Anwendung:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/applications/:projectId
|
||||
* Anwendungs-Konfiguration entfernen
|
||||
*/
|
||||
router.delete('/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
const result = db.prepare('DELETE FROM applications WHERE project_id = ?').run(projectId);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt gefunden' });
|
||||
}
|
||||
|
||||
logger.info(`Anwendung gelöscht für Projekt ${projectId}`);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen der Anwendung:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/applications/user/base-path
|
||||
* Basis-Pfad des aktuellen Benutzers abrufen
|
||||
*/
|
||||
router.get('/user/base-path', (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const db = getDb();
|
||||
|
||||
const user = db.prepare('SELECT repositories_base_path FROM users WHERE id = ?').get(userId);
|
||||
|
||||
res.json({
|
||||
basePath: user?.repositories_base_path || null,
|
||||
configured: !!user?.repositories_base_path
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des Basis-Pfads:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/applications/user/base-path
|
||||
* Basis-Pfad des aktuellen Benutzers setzen
|
||||
*/
|
||||
router.put('/user/base-path', (req, res) => {
|
||||
try {
|
||||
const { basePath } = req.body;
|
||||
const userId = req.user.id;
|
||||
const db = getDb();
|
||||
|
||||
if (!basePath) {
|
||||
return res.status(400).json({ error: 'basePath ist erforderlich' });
|
||||
}
|
||||
|
||||
// Prüfe ob der Pfad erreichbar ist
|
||||
if (!gitService.isPathAccessible(basePath)) {
|
||||
return res.status(400).json({
|
||||
error: 'Pfad nicht erreichbar. Stelle sicher, dass das Laufwerk in Docker gemountet ist.',
|
||||
hint: 'Gemountete Laufwerke: C:, D:, E:'
|
||||
});
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET repositories_base_path = ? WHERE id = ?').run(basePath, userId);
|
||||
|
||||
logger.info(`Basis-Pfad gesetzt für Benutzer ${userId}: ${basePath}`);
|
||||
res.json({ success: true, basePath });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Setzen des Basis-Pfads:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/applications/:projectId/sync
|
||||
* Synchronisierungszeitpunkt aktualisieren
|
||||
*/
|
||||
router.post('/:projectId/sync', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const db = getDb();
|
||||
|
||||
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren der Synchronisierung:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
319
backend/routes/auth.js
Normale Datei
319
backend/routes/auth.js
Normale Datei
@ -0,0 +1,319 @@
|
||||
/**
|
||||
* TASKMATE - Auth Routes
|
||||
* ======================
|
||||
* Login, Logout, Token-Refresh
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const bcrypt = require('bcryptjs');
|
||||
const { getDb } = require('../database');
|
||||
const { generateToken, authenticateToken } = require('../middleware/auth');
|
||||
const { getTokenForUser } = require('../middleware/csrf');
|
||||
const { validatePassword } = require('../middleware/validation');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Konfiguration
|
||||
const MAX_LOGIN_ATTEMPTS = parseInt(process.env.MAX_LOGIN_ATTEMPTS) || 5;
|
||||
const LOCKOUT_DURATION = (parseInt(process.env.LOCKOUT_DURATION_MINUTES) || 15) * 60 * 1000;
|
||||
|
||||
/**
|
||||
* POST /api/auth/login
|
||||
* Benutzer anmelden
|
||||
* - Admin-User loggt sich mit username "admin" ein
|
||||
* - Alle anderen User loggen sich mit E-Mail ein
|
||||
*/
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
const ip = req.ip || req.connection.remoteAddress;
|
||||
const userAgent = req.headers['user-agent'];
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: 'E-Mail/Benutzername und Passwort erforderlich' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Benutzer suchen: Zuerst nach Username "admin", dann nach E-Mail
|
||||
let user;
|
||||
if (username.toLowerCase() === 'admin') {
|
||||
// Admin-User per Username suchen
|
||||
user = db.prepare('SELECT * FROM users WHERE username = ?').get('admin');
|
||||
} else {
|
||||
// Normale User per E-Mail suchen
|
||||
user = db.prepare('SELECT * FROM users WHERE email = ?').get(username);
|
||||
}
|
||||
|
||||
// Audit-Log Eintrag vorbereiten
|
||||
const logAttempt = (userId, success) => {
|
||||
db.prepare(`
|
||||
INSERT INTO login_audit (user_id, ip_address, success, user_agent)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(userId, ip, success ? 1 : 0, userAgent);
|
||||
};
|
||||
|
||||
if (!user) {
|
||||
logger.warn(`Login fehlgeschlagen: Benutzer nicht gefunden - ${username}`);
|
||||
return res.status(401).json({ error: 'Ungültige Anmeldedaten' });
|
||||
}
|
||||
|
||||
// Prüfen ob Account gesperrt ist
|
||||
if (user.locked_until) {
|
||||
const lockedUntil = new Date(user.locked_until).getTime();
|
||||
if (Date.now() < lockedUntil) {
|
||||
const remainingMinutes = Math.ceil((lockedUntil - Date.now()) / 60000);
|
||||
logger.warn(`Login blockiert: Account gesperrt - ${username}`);
|
||||
return res.status(423).json({
|
||||
error: `Account ist gesperrt. Versuche es in ${remainingMinutes} Minuten erneut.`
|
||||
});
|
||||
} else {
|
||||
// Sperre aufheben
|
||||
db.prepare('UPDATE users SET locked_until = NULL, failed_attempts = 0 WHERE id = ?')
|
||||
.run(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Passwort prüfen
|
||||
const validPassword = await bcrypt.compare(password, user.password_hash);
|
||||
|
||||
if (!validPassword) {
|
||||
// Fehlversuche erhöhen
|
||||
const newFailedAttempts = (user.failed_attempts || 0) + 1;
|
||||
|
||||
if (newFailedAttempts >= MAX_LOGIN_ATTEMPTS) {
|
||||
// Account sperren
|
||||
const lockUntil = new Date(Date.now() + LOCKOUT_DURATION).toISOString();
|
||||
db.prepare('UPDATE users SET failed_attempts = ?, locked_until = ? WHERE id = ?')
|
||||
.run(newFailedAttempts, lockUntil, user.id);
|
||||
logger.warn(`Account gesperrt nach ${MAX_LOGIN_ATTEMPTS} Fehlversuchen: ${username}`);
|
||||
} else {
|
||||
db.prepare('UPDATE users SET failed_attempts = ? WHERE id = ?')
|
||||
.run(newFailedAttempts, user.id);
|
||||
}
|
||||
|
||||
logAttempt(user.id, false);
|
||||
logger.warn(`Login fehlgeschlagen: Falsches Passwort - ${username} (Versuch ${newFailedAttempts})`);
|
||||
|
||||
const remainingAttempts = MAX_LOGIN_ATTEMPTS - newFailedAttempts;
|
||||
return res.status(401).json({
|
||||
error: 'Ungültige Anmeldedaten',
|
||||
remainingAttempts: remainingAttempts > 0 ? remainingAttempts : 0
|
||||
});
|
||||
}
|
||||
|
||||
// Login erfolgreich - Fehlversuche zurücksetzen
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET failed_attempts = 0, locked_until = NULL, last_login = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(user.id);
|
||||
|
||||
logAttempt(user.id, true);
|
||||
|
||||
// JWT-Token generieren
|
||||
const token = generateToken(user);
|
||||
|
||||
// CSRF-Token generieren
|
||||
const csrfToken = getTokenForUser(user.id);
|
||||
|
||||
logger.info(`Login erfolgreich: ${username}`);
|
||||
|
||||
// Permissions parsen
|
||||
let permissions = [];
|
||||
try {
|
||||
permissions = JSON.parse(user.permissions || '[]');
|
||||
} catch (e) {
|
||||
permissions = [];
|
||||
}
|
||||
|
||||
res.json({
|
||||
token,
|
||||
csrfToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
color: user.color,
|
||||
role: user.role || 'user',
|
||||
permissions: permissions
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Login-Fehler:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Benutzer abmelden
|
||||
*/
|
||||
router.post('/logout', authenticateToken, (req, res) => {
|
||||
// Bei JWT gibt es serverseitig nichts zu tun
|
||||
// Client muss Token löschen
|
||||
logger.info(`Logout: ${req.user.username}`);
|
||||
res.json({ message: 'Erfolgreich abgemeldet' });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/me
|
||||
* Aktuellen Benutzer abrufen
|
||||
*/
|
||||
router.get('/me', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT id, username, display_name, color, role, permissions FROM users WHERE id = ?')
|
||||
.get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
|
||||
// CSRF-Token erneuern
|
||||
const csrfToken = getTokenForUser(user.id);
|
||||
|
||||
// Permissions parsen
|
||||
let permissions = [];
|
||||
try {
|
||||
permissions = JSON.parse(user.permissions || '[]');
|
||||
} catch (e) {
|
||||
permissions = [];
|
||||
}
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
displayName: user.display_name,
|
||||
color: user.color,
|
||||
role: user.role || 'user',
|
||||
permissions: permissions
|
||||
},
|
||||
csrfToken
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei /me:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/refresh
|
||||
* Token erneuern
|
||||
*/
|
||||
router.post('/refresh', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'Benutzer nicht gefunden' });
|
||||
}
|
||||
|
||||
const token = generateToken(user);
|
||||
const csrfToken = getTokenForUser(user.id);
|
||||
|
||||
res.json({ token, csrfToken });
|
||||
} catch (error) {
|
||||
logger.error('Token-Refresh Fehler:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/auth/password
|
||||
* Passwort ändern
|
||||
*/
|
||||
router.put('/password', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({ error: 'Aktuelles und neues Passwort erforderlich' });
|
||||
}
|
||||
|
||||
// Passwort-Richtlinien prüfen
|
||||
const passwordErrors = validatePassword(newPassword);
|
||||
if (passwordErrors.length > 0) {
|
||||
return res.status(400).json({ error: passwordErrors.join('. ') });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(req.user.id);
|
||||
|
||||
// Aktuelles Passwort prüfen
|
||||
const validPassword = await bcrypt.compare(currentPassword, user.password_hash);
|
||||
if (!validPassword) {
|
||||
return res.status(401).json({ error: 'Aktuelles Passwort ist falsch' });
|
||||
}
|
||||
|
||||
// Neues Passwort hashen und speichern
|
||||
const newHash = await bcrypt.hash(newPassword, 12);
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(newHash, user.id);
|
||||
|
||||
logger.info(`Passwort geändert: ${user.username}`);
|
||||
res.json({ message: 'Passwort erfolgreich geändert' });
|
||||
} catch (error) {
|
||||
logger.error('Passwort-Änderung Fehler:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/auth/color
|
||||
* Benutzerfarbe ändern
|
||||
*/
|
||||
router.put('/color', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const { color } = req.body;
|
||||
|
||||
if (!color) {
|
||||
return res.status(400).json({ error: 'Farbe erforderlich' });
|
||||
}
|
||||
|
||||
// Validate hex color format
|
||||
const hexColorRegex = /^#[0-9A-Fa-f]{6}$/;
|
||||
if (!hexColorRegex.test(color)) {
|
||||
return res.status(400).json({ error: 'Ungültiges Farbformat (erwartet: #RRGGBB)' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE users SET color = ? WHERE id = ?').run(color, req.user.id);
|
||||
|
||||
logger.info(`Farbe geändert: ${req.user.username} -> ${color}`);
|
||||
res.json({ message: 'Farbe erfolgreich geändert', color });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Ändern der Farbe:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/auth/users
|
||||
* Alle Benutzer abrufen (für Zuweisung)
|
||||
* Admin-Benutzer werden ausgeschlossen, da sie nur fuer die Benutzerverwaltung sind
|
||||
*/
|
||||
router.get('/users', authenticateToken, (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
// Nur regulaere Benutzer (nicht Admins) fuer Aufgaben-Zuweisung
|
||||
const users = db.prepare(`
|
||||
SELECT id, username, display_name, color
|
||||
FROM users
|
||||
WHERE role != 'admin' OR role IS NULL
|
||||
`).all();
|
||||
|
||||
res.json(users.map(u => ({
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
displayName: u.display_name,
|
||||
color: u.color
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Benutzer:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
302
backend/routes/columns.js
Normale Datei
302
backend/routes/columns.js
Normale Datei
@ -0,0 +1,302 @@
|
||||
/**
|
||||
* TASKMATE - Column Routes
|
||||
* ========================
|
||||
* CRUD für Board-Spalten
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators } = require('../middleware/validation');
|
||||
|
||||
/**
|
||||
* GET /api/columns/:projectId
|
||||
* Alle Spalten eines Projekts
|
||||
*/
|
||||
router.get('/:projectId', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const columns = db.prepare(`
|
||||
SELECT * FROM columns WHERE project_id = ? ORDER BY position
|
||||
`).all(req.params.projectId);
|
||||
|
||||
res.json(columns.map(c => ({
|
||||
id: c.id,
|
||||
projectId: c.project_id,
|
||||
name: c.name,
|
||||
position: c.position,
|
||||
color: c.color,
|
||||
filterCategory: c.filter_category || 'in_progress'
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Spalten:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/columns
|
||||
* Neue Spalte erstellen
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { projectId, name, color, filterCategory } = req.body;
|
||||
|
||||
// Validierung
|
||||
const errors = [];
|
||||
errors.push(validators.required(projectId, 'Projekt-ID'));
|
||||
errors.push(validators.required(name, 'Name'));
|
||||
errors.push(validators.maxLength(name, 50, 'Name'));
|
||||
if (color) errors.push(validators.hexColor(color, 'Farbe'));
|
||||
|
||||
const firstError = errors.find(e => e !== null);
|
||||
if (firstError) {
|
||||
return res.status(400).json({ error: firstError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Höchste Position ermitteln
|
||||
const maxPos = db.prepare(
|
||||
'SELECT COALESCE(MAX(position), -1) as max FROM columns WHERE project_id = ?'
|
||||
).get(projectId).max;
|
||||
|
||||
// Spalte erstellen mit filter_category
|
||||
const result = db.prepare(`
|
||||
INSERT INTO columns (project_id, name, position, color, filter_category)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(projectId, name, maxPos + 1, color || null, filterCategory || 'in_progress');
|
||||
|
||||
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Spalte erstellt: ${name} in Projekt ${projectId} (Filter: ${filterCategory || 'in_progress'})`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${projectId}`).emit('column:created', {
|
||||
id: column.id,
|
||||
projectId: column.project_id,
|
||||
name: column.name,
|
||||
position: column.position,
|
||||
color: column.color,
|
||||
filterCategory: column.filter_category
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: column.id,
|
||||
projectId: column.project_id,
|
||||
name: column.name,
|
||||
position: column.position,
|
||||
color: column.color,
|
||||
filterCategory: column.filter_category
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen der Spalte:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/columns/:id
|
||||
* Spalte aktualisieren
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const columnId = req.params.id;
|
||||
const { name, color, filterCategory } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (name) {
|
||||
const nameError = validators.maxLength(name, 50, 'Name');
|
||||
if (nameError) {
|
||||
return res.status(400).json({ error: nameError });
|
||||
}
|
||||
}
|
||||
if (color) {
|
||||
const colorError = validators.hexColor(color, 'Farbe');
|
||||
if (colorError) {
|
||||
return res.status(400).json({ error: colorError });
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM columns WHERE id = ?').get(columnId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE columns
|
||||
SET name = COALESCE(?, name), color = ?, filter_category = COALESCE(?, filter_category)
|
||||
WHERE id = ?
|
||||
`).run(name || null, color !== undefined ? color : existing.color, filterCategory || null, columnId);
|
||||
|
||||
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(columnId);
|
||||
|
||||
logger.info(`Spalte aktualisiert: ${column.name} (ID: ${columnId})`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${column.project_id}`).emit('column:updated', {
|
||||
id: column.id,
|
||||
name: column.name,
|
||||
color: column.color,
|
||||
filterCategory: column.filter_category
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: column.id,
|
||||
projectId: column.project_id,
|
||||
name: column.name,
|
||||
position: column.position,
|
||||
color: column.color,
|
||||
filterCategory: column.filter_category
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren der Spalte:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/columns/:id/position
|
||||
* Spalten-Position ändern (Reihenfolge)
|
||||
*/
|
||||
router.put('/:id/position', (req, res) => {
|
||||
try {
|
||||
const columnId = req.params.id;
|
||||
const { newPosition } = req.body;
|
||||
|
||||
if (typeof newPosition !== 'number' || newPosition < 0) {
|
||||
return res.status(400).json({ error: 'Ungültige Position' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(columnId);
|
||||
if (!column) {
|
||||
return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
}
|
||||
|
||||
const oldPosition = column.position;
|
||||
const projectId = column.project_id;
|
||||
|
||||
// Positionen der anderen Spalten anpassen
|
||||
if (newPosition > oldPosition) {
|
||||
// Nach rechts verschoben: Spalten dazwischen nach links
|
||||
db.prepare(`
|
||||
UPDATE columns
|
||||
SET position = position - 1
|
||||
WHERE project_id = ? AND position > ? AND position <= ?
|
||||
`).run(projectId, oldPosition, newPosition);
|
||||
} else if (newPosition < oldPosition) {
|
||||
// Nach links verschoben: Spalten dazwischen nach rechts
|
||||
db.prepare(`
|
||||
UPDATE columns
|
||||
SET position = position + 1
|
||||
WHERE project_id = ? AND position >= ? AND position < ?
|
||||
`).run(projectId, newPosition, oldPosition);
|
||||
}
|
||||
|
||||
// Neue Position setzen
|
||||
db.prepare('UPDATE columns SET position = ? WHERE id = ?').run(newPosition, columnId);
|
||||
|
||||
// Alle Spalten des Projekts zurückgeben
|
||||
const columns = db.prepare(
|
||||
'SELECT * FROM columns WHERE project_id = ? ORDER BY position'
|
||||
).all(projectId);
|
||||
|
||||
logger.info(`Spalte ${column.name} von Position ${oldPosition} zu ${newPosition} verschoben`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${projectId}`).emit('columns:reordered', {
|
||||
columns: columns.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
position: c.position,
|
||||
color: c.color,
|
||||
filterCategory: c.filter_category
|
||||
}))
|
||||
});
|
||||
|
||||
res.json({
|
||||
columns: columns.map(c => ({
|
||||
id: c.id,
|
||||
projectId: c.project_id,
|
||||
name: c.name,
|
||||
position: c.position,
|
||||
color: c.color,
|
||||
filterCategory: c.filter_category
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Verschieben der Spalte:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/columns/:id
|
||||
* Spalte löschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const columnId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const column = db.prepare('SELECT * FROM columns WHERE id = ?').get(columnId);
|
||||
if (!column) {
|
||||
return res.status(404).json({ error: 'Spalte nicht gefunden' });
|
||||
}
|
||||
|
||||
// Prüfen ob Aufgaben in der Spalte sind
|
||||
const taskCount = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM tasks WHERE column_id = ?'
|
||||
).get(columnId).count;
|
||||
|
||||
if (taskCount > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Spalte enthält noch Aufgaben. Verschiebe oder lösche diese zuerst.'
|
||||
});
|
||||
}
|
||||
|
||||
// Mindestens eine Spalte muss bleiben
|
||||
const columnCount = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM columns WHERE project_id = ?'
|
||||
).get(column.project_id).count;
|
||||
|
||||
if (columnCount <= 1) {
|
||||
return res.status(400).json({
|
||||
error: 'Mindestens eine Spalte muss vorhanden sein.'
|
||||
});
|
||||
}
|
||||
|
||||
// Spalte löschen
|
||||
db.prepare('DELETE FROM columns WHERE id = ?').run(columnId);
|
||||
|
||||
// Positionen neu nummerieren
|
||||
const remainingColumns = db.prepare(
|
||||
'SELECT id FROM columns WHERE project_id = ? ORDER BY position'
|
||||
).all(column.project_id);
|
||||
|
||||
remainingColumns.forEach((col, index) => {
|
||||
db.prepare('UPDATE columns SET position = ? WHERE id = ?').run(index, col.id);
|
||||
});
|
||||
|
||||
logger.info(`Spalte gelöscht: ${column.name} (ID: ${columnId})`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${column.project_id}`).emit('column:deleted', { id: columnId });
|
||||
|
||||
res.json({ message: 'Spalte gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen der Spalte:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
279
backend/routes/comments.js
Normale Datei
279
backend/routes/comments.js
Normale Datei
@ -0,0 +1,279 @@
|
||||
/**
|
||||
* TASKMATE - Comment Routes
|
||||
* =========================
|
||||
* CRUD für Kommentare
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators, sanitizeMarkdown } = require('../middleware/validation');
|
||||
const notificationService = require('../services/notificationService');
|
||||
|
||||
/**
|
||||
* GET /api/comments/:taskId
|
||||
* Alle Kommentare einer Aufgabe
|
||||
*/
|
||||
router.get('/:taskId', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const comments = db.prepare(`
|
||||
SELECT c.*, u.display_name, u.color
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.task_id = ?
|
||||
ORDER BY c.created_at ASC
|
||||
`).all(req.params.taskId);
|
||||
|
||||
res.json(comments.map(c => ({
|
||||
id: c.id,
|
||||
taskId: c.task_id,
|
||||
userId: c.user_id,
|
||||
userName: c.display_name,
|
||||
userColor: c.color,
|
||||
content: c.content,
|
||||
createdAt: c.created_at
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Kommentare:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/comments
|
||||
* Neuen Kommentar erstellen
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { taskId, content } = req.body;
|
||||
|
||||
// Validierung
|
||||
const contentError = validators.required(content, 'Inhalt') ||
|
||||
validators.maxLength(content, 5000, 'Inhalt');
|
||||
if (contentError) {
|
||||
return res.status(400).json({ error: contentError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Task prüfen
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
// Inhalt bereinigen (Markdown erlaubt)
|
||||
const sanitizedContent = sanitizeMarkdown(content);
|
||||
|
||||
// @Erwähnungen verarbeiten
|
||||
const mentions = content.match(/@(\w+)/g);
|
||||
const mentionedUsers = [];
|
||||
|
||||
if (mentions) {
|
||||
mentions.forEach(mention => {
|
||||
const username = mention.substring(1);
|
||||
const user = db.prepare('SELECT id, display_name FROM users WHERE username = ?').get(username);
|
||||
if (user) {
|
||||
mentionedUsers.push(user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Kommentar erstellen
|
||||
const result = db.prepare(`
|
||||
INSERT INTO comments (task_id, user_id, content)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(taskId, req.user.id, sanitizedContent);
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
|
||||
|
||||
// Historie
|
||||
db.prepare(`
|
||||
INSERT INTO history (task_id, user_id, action, new_value)
|
||||
VALUES (?, ?, 'commented', ?)
|
||||
`).run(taskId, req.user.id, sanitizedContent.substring(0, 100));
|
||||
|
||||
const comment = db.prepare(`
|
||||
SELECT c.*, u.display_name, u.color
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Kommentar erstellt in Task ${taskId} von ${req.user.username}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('comment:created', {
|
||||
taskId,
|
||||
comment: {
|
||||
id: comment.id,
|
||||
taskId: comment.task_id,
|
||||
userId: comment.user_id,
|
||||
userName: comment.display_name,
|
||||
userColor: comment.color,
|
||||
content: comment.content,
|
||||
createdAt: comment.created_at
|
||||
},
|
||||
mentionedUsers
|
||||
});
|
||||
|
||||
// Benachrichtigungen senden
|
||||
// 1. Benachrichtigung an zugewiesene Mitarbeiter der Aufgabe
|
||||
const assignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
|
||||
const mentionedUserIds = mentionedUsers.map(u => u.id);
|
||||
|
||||
assignees.forEach(a => {
|
||||
// Nicht an Kommentator und nicht an erwähnte Benutzer (die bekommen separate Benachrichtigung)
|
||||
if (a.user_id !== req.user.id && !mentionedUserIds.includes(a.user_id)) {
|
||||
notificationService.create(a.user_id, 'comment:created', {
|
||||
taskId: parseInt(taskId),
|
||||
taskTitle: task.title,
|
||||
projectId: task.project_id,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Benachrichtigung an erwähnte Benutzer
|
||||
mentionedUsers.forEach(user => {
|
||||
if (user.id !== req.user.id) {
|
||||
notificationService.create(user.id, 'comment:mention', {
|
||||
taskId: parseInt(taskId),
|
||||
taskTitle: task.title,
|
||||
projectId: task.project_id,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: comment.id,
|
||||
taskId: comment.task_id,
|
||||
userId: comment.user_id,
|
||||
userName: comment.display_name,
|
||||
userColor: comment.color,
|
||||
content: comment.content,
|
||||
createdAt: comment.created_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Kommentars:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/comments/:id
|
||||
* Kommentar bearbeiten (nur eigene)
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const commentId = req.params.id;
|
||||
const { content } = req.body;
|
||||
|
||||
// Validierung
|
||||
const contentError = validators.required(content, 'Inhalt') ||
|
||||
validators.maxLength(content, 5000, 'Inhalt');
|
||||
if (contentError) {
|
||||
return res.status(400).json({ error: contentError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId);
|
||||
if (!comment) {
|
||||
return res.status(404).json({ error: 'Kommentar nicht gefunden' });
|
||||
}
|
||||
|
||||
// Nur eigene Kommentare bearbeiten
|
||||
if (comment.user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Nur eigene Kommentare können bearbeitet werden' });
|
||||
}
|
||||
|
||||
const sanitizedContent = sanitizeMarkdown(content);
|
||||
|
||||
db.prepare('UPDATE comments SET content = ? WHERE id = ?')
|
||||
.run(sanitizedContent, commentId);
|
||||
|
||||
const updated = db.prepare(`
|
||||
SELECT c.*, u.display_name, u.color
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.id = ?
|
||||
`).get(commentId);
|
||||
|
||||
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(comment.task_id);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('comment:updated', {
|
||||
taskId: comment.task_id,
|
||||
comment: {
|
||||
id: updated.id,
|
||||
taskId: updated.task_id,
|
||||
userId: updated.user_id,
|
||||
userName: updated.display_name,
|
||||
userColor: updated.color,
|
||||
content: updated.content,
|
||||
createdAt: updated.created_at
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
taskId: updated.task_id,
|
||||
userId: updated.user_id,
|
||||
userName: updated.display_name,
|
||||
userColor: updated.color,
|
||||
content: updated.content,
|
||||
createdAt: updated.created_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren des Kommentars:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/comments/:id
|
||||
* Kommentar löschen (nur eigene)
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const commentId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(commentId);
|
||||
if (!comment) {
|
||||
return res.status(404).json({ error: 'Kommentar nicht gefunden' });
|
||||
}
|
||||
|
||||
// Nur eigene Kommentare löschen
|
||||
if (comment.user_id !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Nur eigene Kommentare können gelöscht werden' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM comments WHERE id = ?').run(commentId);
|
||||
|
||||
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(comment.task_id);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('comment:deleted', {
|
||||
taskId: comment.task_id,
|
||||
commentId
|
||||
});
|
||||
|
||||
res.json({ message: 'Kommentar gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Kommentars:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
230
backend/routes/export.js
Normale Datei
230
backend/routes/export.js
Normale Datei
@ -0,0 +1,230 @@
|
||||
/**
|
||||
* TASKMATE - Export Routes
|
||||
* ========================
|
||||
* Export in JSON und CSV
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* GET /api/export/project/:id/json
|
||||
* Projekt als JSON exportieren
|
||||
*/
|
||||
router.get('/project/:id/json', (req, res) => {
|
||||
try {
|
||||
const projectId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
// Projekt
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' });
|
||||
}
|
||||
|
||||
// Spalten
|
||||
const columns = db.prepare('SELECT * FROM columns WHERE project_id = ? ORDER BY position').all(projectId);
|
||||
|
||||
// Labels
|
||||
const labels = db.prepare('SELECT * FROM labels WHERE project_id = ?').all(projectId);
|
||||
|
||||
// Aufgaben mit allen Details
|
||||
const tasks = db.prepare('SELECT * FROM tasks WHERE project_id = ?').all(projectId);
|
||||
|
||||
const tasksWithDetails = tasks.map(task => {
|
||||
const taskLabels = db.prepare(`
|
||||
SELECT l.* FROM labels l
|
||||
JOIN task_labels tl ON l.id = tl.label_id
|
||||
WHERE tl.task_id = ?
|
||||
`).all(task.id);
|
||||
|
||||
const subtasks = db.prepare('SELECT * FROM subtasks WHERE task_id = ? ORDER BY position').all(task.id);
|
||||
const comments = db.prepare(`
|
||||
SELECT c.*, u.display_name FROM comments c
|
||||
LEFT JOIN users u ON c.user_id = u.id
|
||||
WHERE c.task_id = ?
|
||||
`).all(task.id);
|
||||
const attachments = db.prepare('SELECT * FROM attachments WHERE task_id = ?').all(task.id);
|
||||
const links = db.prepare('SELECT * FROM links WHERE task_id = ?').all(task.id);
|
||||
|
||||
return {
|
||||
...task,
|
||||
labels: taskLabels,
|
||||
subtasks,
|
||||
comments,
|
||||
attachments,
|
||||
links
|
||||
};
|
||||
});
|
||||
|
||||
// Vorlagen
|
||||
const templates = db.prepare('SELECT * FROM task_templates WHERE project_id = ?').all(projectId);
|
||||
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportedBy: req.user.username,
|
||||
version: '1.0',
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
createdAt: project.created_at
|
||||
},
|
||||
columns: columns.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
position: c.position,
|
||||
color: c.color
|
||||
})),
|
||||
labels: labels.map(l => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
color: l.color
|
||||
})),
|
||||
tasks: tasksWithDetails,
|
||||
templates
|
||||
};
|
||||
|
||||
logger.info(`Projekt exportiert als JSON: ${project.name}`);
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${project.name.replace(/[^a-z0-9]/gi, '_')}_export.json"`);
|
||||
res.json(exportData);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim JSON-Export:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/export/project/:id/csv
|
||||
* Aufgaben als CSV exportieren
|
||||
*/
|
||||
router.get('/project/:id/csv', (req, res) => {
|
||||
try {
|
||||
const projectId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' });
|
||||
}
|
||||
|
||||
const tasks = db.prepare(`
|
||||
SELECT
|
||||
t.*,
|
||||
c.name as column_name,
|
||||
u.display_name as assigned_name
|
||||
FROM tasks t
|
||||
LEFT JOIN columns c ON t.column_id = c.id
|
||||
LEFT JOIN users u ON t.assigned_to = u.id
|
||||
WHERE t.project_id = ?
|
||||
ORDER BY c.position, t.position
|
||||
`).all(projectId);
|
||||
|
||||
// CSV Header
|
||||
const headers = [
|
||||
'ID', 'Titel', 'Beschreibung', 'Status', 'Priorität',
|
||||
'Fälligkeitsdatum', 'Zugewiesen an', 'Zeitschätzung (Min)',
|
||||
'Erstellt am', 'Archiviert'
|
||||
];
|
||||
|
||||
// CSV Zeilen
|
||||
const rows = tasks.map(task => [
|
||||
task.id,
|
||||
escapeCsvField(task.title),
|
||||
escapeCsvField(task.description || ''),
|
||||
task.column_name,
|
||||
task.priority,
|
||||
task.due_date || '',
|
||||
task.assigned_name || '',
|
||||
task.time_estimate_min || '',
|
||||
task.created_at,
|
||||
task.archived ? 'Ja' : 'Nein'
|
||||
]);
|
||||
|
||||
// CSV zusammenbauen
|
||||
const csv = [
|
||||
headers.join(';'),
|
||||
...rows.map(row => row.join(';'))
|
||||
].join('\n');
|
||||
|
||||
logger.info(`Projekt exportiert als CSV: ${project.name}`);
|
||||
|
||||
res.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${project.name.replace(/[^a-z0-9]/gi, '_')}_export.csv"`);
|
||||
// BOM für Excel UTF-8 Erkennung
|
||||
res.send('\ufeff' + csv);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim CSV-Export:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/export/all/json
|
||||
* Alle Daten exportieren (Backup)
|
||||
*/
|
||||
router.get('/all/json', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
const projects = db.prepare('SELECT * FROM projects').all();
|
||||
const columns = db.prepare('SELECT * FROM columns').all();
|
||||
const labels = db.prepare('SELECT * FROM labels').all();
|
||||
const tasks = db.prepare('SELECT * FROM tasks').all();
|
||||
const subtasks = db.prepare('SELECT * FROM subtasks').all();
|
||||
const comments = db.prepare('SELECT * FROM comments').all();
|
||||
const taskLabels = db.prepare('SELECT * FROM task_labels').all();
|
||||
const attachments = db.prepare('SELECT * FROM attachments').all();
|
||||
const links = db.prepare('SELECT * FROM links').all();
|
||||
const templates = db.prepare('SELECT * FROM task_templates').all();
|
||||
const history = db.prepare('SELECT * FROM history').all();
|
||||
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
exportedBy: req.user.username,
|
||||
version: '1.0',
|
||||
data: {
|
||||
projects,
|
||||
columns,
|
||||
labels,
|
||||
tasks,
|
||||
subtasks,
|
||||
comments,
|
||||
taskLabels,
|
||||
attachments,
|
||||
links,
|
||||
templates,
|
||||
history
|
||||
}
|
||||
};
|
||||
|
||||
logger.info('Vollständiger Export durchgeführt');
|
||||
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="taskmate_backup_${Date.now()}.json"`);
|
||||
res.json(exportData);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Voll-Export:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: CSV-Feld escapen
|
||||
*/
|
||||
function escapeCsvField(field) {
|
||||
if (typeof field !== 'string') return field;
|
||||
|
||||
// Wenn Feld Semikolon, Anführungszeichen oder Zeilenumbruch enthält
|
||||
if (field.includes(';') || field.includes('"') || field.includes('\n')) {
|
||||
// Anführungszeichen verdoppeln und in Anführungszeichen setzen
|
||||
return '"' + field.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
238
backend/routes/files.js
Normale Datei
238
backend/routes/files.js
Normale Datei
@ -0,0 +1,238 @@
|
||||
/**
|
||||
* TASKMATE - File Routes
|
||||
* ======================
|
||||
* Upload, Download, Löschen von Dateien
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { upload, deleteFile, formatFileSize, isImage, getFileIcon, UPLOAD_DIR } = require('../middleware/upload');
|
||||
const csrfProtection = require('../middleware/csrf');
|
||||
|
||||
/**
|
||||
* GET /api/files/:taskId
|
||||
* Alle Dateien einer Aufgabe
|
||||
*/
|
||||
router.get('/:taskId', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const attachments = db.prepare(`
|
||||
SELECT a.*, u.display_name as uploader_name
|
||||
FROM attachments a
|
||||
LEFT JOIN users u ON a.uploaded_by = u.id
|
||||
WHERE a.task_id = ?
|
||||
ORDER BY a.uploaded_at DESC
|
||||
`).all(req.params.taskId);
|
||||
|
||||
res.json(attachments.map(a => ({
|
||||
id: a.id,
|
||||
taskId: a.task_id,
|
||||
filename: a.filename,
|
||||
originalName: a.original_name,
|
||||
mimeType: a.mime_type,
|
||||
sizeBytes: a.size_bytes,
|
||||
sizeFormatted: formatFileSize(a.size_bytes),
|
||||
isImage: isImage(a.mime_type),
|
||||
icon: getFileIcon(a.mime_type),
|
||||
uploadedBy: a.uploaded_by,
|
||||
uploaderName: a.uploader_name,
|
||||
uploadedAt: a.uploaded_at
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Dateien:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/files/:taskId
|
||||
* Datei(en) hochladen
|
||||
*/
|
||||
router.post('/:taskId', csrfProtection, upload.array('files', 10), (req, res) => {
|
||||
try {
|
||||
const taskId = req.params.taskId;
|
||||
const db = getDb();
|
||||
|
||||
// Task prüfen
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!task) {
|
||||
// Hochgeladene Dateien löschen
|
||||
req.files?.forEach(f => fs.unlinkSync(f.path));
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
if (!req.files || req.files.length === 0) {
|
||||
return res.status(400).json({ error: 'Keine Dateien hochgeladen' });
|
||||
}
|
||||
|
||||
const insertAttachment = db.prepare(`
|
||||
INSERT INTO attachments (task_id, filename, original_name, mime_type, size_bytes, uploaded_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
const attachments = [];
|
||||
|
||||
req.files.forEach(file => {
|
||||
const result = insertAttachment.run(
|
||||
taskId,
|
||||
`task_${taskId}/${file.filename}`,
|
||||
file.originalname,
|
||||
file.mimetype,
|
||||
file.size,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
attachments.push({
|
||||
id: result.lastInsertRowid,
|
||||
taskId: parseInt(taskId),
|
||||
filename: `task_${taskId}/${file.filename}`,
|
||||
originalName: file.originalname,
|
||||
mimeType: file.mimetype,
|
||||
sizeBytes: file.size,
|
||||
sizeFormatted: formatFileSize(file.size),
|
||||
isImage: isImage(file.mimetype),
|
||||
icon: getFileIcon(file.mimetype),
|
||||
uploadedBy: req.user.id,
|
||||
uploaderName: req.user.displayName,
|
||||
uploadedAt: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
|
||||
|
||||
// Historie
|
||||
db.prepare(`
|
||||
INSERT INTO history (task_id, user_id, action, new_value)
|
||||
VALUES (?, ?, 'attachment_added', ?)
|
||||
`).run(taskId, req.user.id, attachments.map(a => a.originalName).join(', '));
|
||||
|
||||
logger.info(`${attachments.length} Datei(en) hochgeladen für Task ${taskId}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('files:uploaded', {
|
||||
taskId,
|
||||
attachments
|
||||
});
|
||||
|
||||
res.status(201).json({ attachments });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Hochladen:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/files/download/:id
|
||||
* Datei herunterladen
|
||||
*/
|
||||
router.get('/download/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const attachment = db.prepare('SELECT * FROM attachments WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, attachment.filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
logger.error(`Datei existiert nicht: ${filePath}`);
|
||||
return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
}
|
||||
|
||||
res.download(filePath, attachment.original_name);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Download:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/files/preview/:id
|
||||
* Bild-Vorschau
|
||||
*/
|
||||
router.get('/preview/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const attachment = db.prepare('SELECT * FROM attachments WHERE id = ?').get(req.params.id);
|
||||
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
}
|
||||
|
||||
if (!isImage(attachment.mime_type)) {
|
||||
return res.status(400).json({ error: 'Keine Bilddatei' });
|
||||
}
|
||||
|
||||
const filePath = path.join(UPLOAD_DIR, attachment.filename);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', attachment.mime_type);
|
||||
res.sendFile(filePath);
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Vorschau:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/files/:id
|
||||
* Datei löschen
|
||||
*/
|
||||
router.delete('/:id', csrfProtection, (req, res) => {
|
||||
try {
|
||||
const attachmentId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const attachment = db.prepare('SELECT * FROM attachments WHERE id = ?').get(attachmentId);
|
||||
if (!attachment) {
|
||||
return res.status(404).json({ error: 'Datei nicht gefunden' });
|
||||
}
|
||||
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(attachment.task_id);
|
||||
|
||||
// Datei vom Dateisystem löschen
|
||||
const filePath = path.join(UPLOAD_DIR, attachment.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
// Aus Datenbank löschen
|
||||
db.prepare('DELETE FROM attachments WHERE id = ?').run(attachmentId);
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(attachment.task_id);
|
||||
|
||||
// Historie
|
||||
db.prepare(`
|
||||
INSERT INTO history (task_id, user_id, action, old_value)
|
||||
VALUES (?, ?, 'attachment_removed', ?)
|
||||
`).run(attachment.task_id, req.user.id, attachment.original_name);
|
||||
|
||||
logger.info(`Datei gelöscht: ${attachment.original_name}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('file:deleted', {
|
||||
taskId: attachment.task_id,
|
||||
attachmentId
|
||||
});
|
||||
|
||||
res.json({ message: 'Datei gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen der Datei:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
444
backend/routes/git.js
Normale Datei
444
backend/routes/git.js
Normale Datei
@ -0,0 +1,444 @@
|
||||
/**
|
||||
* TASKMATE - Git Route
|
||||
* =====================
|
||||
* API-Endpoints für Git-Operationen
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const gitService = require('../services/gitService');
|
||||
const giteaService = require('../services/giteaService');
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: Anwendung für Projekt abrufen
|
||||
*/
|
||||
function getApplicationForProject(projectId) {
|
||||
const db = getDb();
|
||||
return db.prepare('SELECT * FROM applications WHERE project_id = ?').get(projectId);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/git/clone
|
||||
* Repository klonen
|
||||
*/
|
||||
router.post('/clone', async (req, res) => {
|
||||
try {
|
||||
const { projectId, repoUrl, localPath, branch } = req.body;
|
||||
|
||||
if (!localPath) {
|
||||
return res.status(400).json({ error: 'localPath ist erforderlich' });
|
||||
}
|
||||
|
||||
if (!repoUrl) {
|
||||
return res.status(400).json({ error: 'repoUrl ist erforderlich' });
|
||||
}
|
||||
|
||||
// Clone ausführen
|
||||
const result = await gitService.cloneRepository(repoUrl, localPath, { branch });
|
||||
|
||||
if (result.success && projectId) {
|
||||
// Anwendung aktualisieren
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Klonen:', error);
|
||||
res.status(500).json({ error: 'Serverfehler', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/status/:projectId
|
||||
* Git-Status für ein Projekt abrufen
|
||||
*/
|
||||
router.get('/status/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
const result = gitService.getStatus(application.local_path);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des Status:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/pull/:projectId
|
||||
* Pull für ein Projekt ausführen
|
||||
*/
|
||||
router.post('/pull/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { branch } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
// Fetch zuerst
|
||||
gitService.fetchRemote(application.local_path);
|
||||
|
||||
// Dann Pull
|
||||
const result = gitService.pullChanges(application.local_path, { branch });
|
||||
|
||||
if (result.success) {
|
||||
// Sync-Zeitpunkt aktualisieren
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Pull:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/push/:projectId
|
||||
* Push für ein Projekt ausführen
|
||||
*/
|
||||
router.post('/push/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { branch } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
// Prüfe ob Remote existiert
|
||||
if (!gitService.hasRemote(application.local_path)) {
|
||||
return res.json({
|
||||
success: false,
|
||||
error: 'Kein Remote konfiguriert. Bitte Repository zuerst vorbereiten.'
|
||||
});
|
||||
}
|
||||
|
||||
// Versuche normalen Push, falls das fehlschlägt wegen fehlendem Upstream, push mit -u
|
||||
let result = gitService.pushChanges(application.local_path, { branch });
|
||||
|
||||
// Falls Push wegen fehlendem Upstream fehlschlägt, versuche mit -u
|
||||
if (!result.success && result.error && result.error.includes('no upstream')) {
|
||||
const currentBranch = branch || 'main';
|
||||
result = gitService.pushWithUpstream(application.local_path, currentBranch);
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
// Sync-Zeitpunkt aktualisieren
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Push:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/commit/:projectId
|
||||
* Commit für ein Projekt erstellen
|
||||
*/
|
||||
router.post('/commit/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { message, stageAll } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: 'Commit-Nachricht ist erforderlich' });
|
||||
}
|
||||
|
||||
// Optional: Alle Änderungen stagen
|
||||
if (stageAll !== false) {
|
||||
const stageResult = gitService.stageAll(application.local_path);
|
||||
if (!stageResult.success) {
|
||||
return res.json(stageResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Commit erstellen
|
||||
const result = gitService.commit(application.local_path, message);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Commit:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/commits/:projectId
|
||||
* Commit-Historie für ein Projekt abrufen
|
||||
*/
|
||||
router.get('/commits/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
const result = gitService.getCommitHistory(application.local_path, limit);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Commits:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/branches/:projectId
|
||||
* Branches für ein Projekt abrufen
|
||||
*/
|
||||
router.get('/branches/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
const result = gitService.getBranches(application.local_path);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Branches:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/checkout/:projectId
|
||||
* Branch wechseln
|
||||
*/
|
||||
router.post('/checkout/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { branch } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
if (!branch) {
|
||||
return res.status(400).json({ error: 'Branch ist erforderlich' });
|
||||
}
|
||||
|
||||
const result = gitService.checkoutBranch(application.local_path, branch);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Branch-Wechsel:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/fetch/:projectId
|
||||
* Fetch von Remote ausführen
|
||||
*/
|
||||
router.post('/fetch/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
const result = gitService.fetchRemote(application.local_path);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Fetch:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/stage/:projectId
|
||||
* Alle Änderungen stagen
|
||||
*/
|
||||
router.post('/stage/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
const result = gitService.stageAll(application.local_path);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Stagen:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/git/remote/:projectId
|
||||
* Remote-URL abrufen
|
||||
*/
|
||||
router.get('/remote/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
const result = gitService.getRemoteUrl(application.local_path);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Remote-URL:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/validate-path
|
||||
* Pfad validieren
|
||||
*/
|
||||
router.post('/validate-path', (req, res) => {
|
||||
try {
|
||||
const { path } = req.body;
|
||||
|
||||
if (!path) {
|
||||
return res.status(400).json({ error: 'Pfad ist erforderlich' });
|
||||
}
|
||||
|
||||
const isAccessible = gitService.isPathAccessible(path);
|
||||
const isRepo = isAccessible ? gitService.isGitRepository(path) : false;
|
||||
const hasRemote = isRepo ? gitService.hasRemote(path) : false;
|
||||
|
||||
res.json({
|
||||
valid: isAccessible,
|
||||
isRepository: isRepo,
|
||||
hasRemote: hasRemote,
|
||||
containerPath: gitService.windowsToContainerPath(path)
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei der Pfad-Validierung:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/prepare/:projectId
|
||||
* Repository für Gitea vorbereiten (init, remote hinzufügen)
|
||||
*/
|
||||
router.post('/prepare/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { repoUrl, branch } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
if (!repoUrl) {
|
||||
return res.status(400).json({ error: 'repoUrl ist erforderlich' });
|
||||
}
|
||||
|
||||
const result = gitService.prepareForGitea(application.local_path, repoUrl, { branch });
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Repository vorbereitet für Projekt ${projectId}: ${repoUrl}`);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Vorbereiten des Repositories:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/set-remote/:projectId
|
||||
* Remote für ein Projekt setzen/aktualisieren
|
||||
*/
|
||||
router.post('/set-remote/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { repoUrl } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
if (!repoUrl) {
|
||||
return res.status(400).json({ error: 'repoUrl ist erforderlich' });
|
||||
}
|
||||
|
||||
// Prüfe ob Git-Repo existiert
|
||||
if (!gitService.isGitRepository(application.local_path)) {
|
||||
// Initialisiere Repository
|
||||
const initResult = gitService.initRepository(application.local_path);
|
||||
if (!initResult.success) {
|
||||
return res.json(initResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Remote setzen
|
||||
const result = gitService.setRemote(application.local_path, repoUrl);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Setzen des Remotes:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/git/init-push/:projectId
|
||||
* Initialen Push mit Upstream-Tracking
|
||||
*/
|
||||
router.post('/init-push/:projectId', (req, res) => {
|
||||
try {
|
||||
const { projectId } = req.params;
|
||||
const { branch } = req.body;
|
||||
const application = getApplicationForProject(projectId);
|
||||
|
||||
if (!application) {
|
||||
return res.status(404).json({ error: 'Keine Anwendung für dieses Projekt konfiguriert' });
|
||||
}
|
||||
|
||||
const currentBranch = branch || 'main';
|
||||
const result = gitService.pushWithUpstream(application.local_path, currentBranch);
|
||||
|
||||
if (result.success) {
|
||||
// Sync-Zeitpunkt aktualisieren
|
||||
const db = getDb();
|
||||
db.prepare('UPDATE applications SET last_sync = CURRENT_TIMESTAMP WHERE project_id = ?').run(projectId);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim initialen Push:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
160
backend/routes/gitea.js
Normale Datei
160
backend/routes/gitea.js
Normale Datei
@ -0,0 +1,160 @@
|
||||
/**
|
||||
* TASKMATE - Gitea Route
|
||||
* ======================
|
||||
* API-Endpoints für Gitea-Integration
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const giteaService = require('../services/giteaService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* GET /api/gitea/test
|
||||
* Gitea-Verbindung testen
|
||||
*/
|
||||
router.get('/test', async (req, res) => {
|
||||
try {
|
||||
const result = await giteaService.testConnection();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Testen der Gitea-Verbindung:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
connected: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gitea/repositories
|
||||
* Alle verfügbaren Repositories auflisten
|
||||
*/
|
||||
router.get('/repositories', async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
|
||||
const result = await giteaService.listRepositories({ page, limit });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Auflisten der Repositories:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/gitea/repositories
|
||||
* Neues Repository erstellen
|
||||
*/
|
||||
router.post('/repositories', async (req, res) => {
|
||||
try {
|
||||
const { name, description, private: isPrivate, autoInit, defaultBranch } = req.body;
|
||||
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: 'Repository-Name ist erforderlich' });
|
||||
}
|
||||
|
||||
const result = await giteaService.createRepository(name, {
|
||||
description,
|
||||
private: isPrivate !== false,
|
||||
autoInit: autoInit !== false,
|
||||
defaultBranch: defaultBranch || 'main'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Gitea-Repository erstellt: ${result.repository.fullName}`);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Repositories:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gitea/repositories/:owner/:repo
|
||||
* Repository-Details abrufen
|
||||
*/
|
||||
router.get('/repositories/:owner/:repo', async (req, res) => {
|
||||
try {
|
||||
const { owner, repo } = req.params;
|
||||
const result = await giteaService.getRepository(owner, repo);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Abrufen des Repositories ${req.params.owner}/${req.params.repo}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/gitea/repositories/:owner/:repo
|
||||
* Repository löschen
|
||||
*/
|
||||
router.delete('/repositories/:owner/:repo', async (req, res) => {
|
||||
try {
|
||||
const { owner, repo } = req.params;
|
||||
const result = await giteaService.deleteRepository(owner, repo);
|
||||
|
||||
if (result.success) {
|
||||
logger.info(`Gitea-Repository gelöscht: ${owner}/${repo}`);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Löschen des Repositories ${req.params.owner}/${req.params.repo}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gitea/repositories/:owner/:repo/branches
|
||||
* Branches eines Repositories abrufen
|
||||
*/
|
||||
router.get('/repositories/:owner/:repo/branches', async (req, res) => {
|
||||
try {
|
||||
const { owner, repo } = req.params;
|
||||
const result = await giteaService.getRepositoryBranches(owner, repo);
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Abrufen der Branches für ${req.params.owner}/${req.params.repo}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gitea/repositories/:owner/:repo/commits
|
||||
* Commits eines Repositories abrufen
|
||||
*/
|
||||
router.get('/repositories/:owner/:repo/commits', async (req, res) => {
|
||||
try {
|
||||
const { owner, repo } = req.params;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || 20;
|
||||
const branch = req.query.branch || '';
|
||||
|
||||
const result = await giteaService.getRepositoryCommits(owner, repo, { page, limit, branch });
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Abrufen der Commits für ${req.params.owner}/${req.params.repo}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/gitea/user
|
||||
* Aktuellen Gitea-Benutzer abrufen
|
||||
*/
|
||||
router.get('/user', async (req, res) => {
|
||||
try {
|
||||
const result = await giteaService.getCurrentUser();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des Gitea-Benutzers:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
158
backend/routes/health.js
Normale Datei
158
backend/routes/health.js
Normale Datei
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* TASKMATE - Health Check Routes
|
||||
* ==============================
|
||||
* Server-Status und Health-Check Endpoints
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getDb } = require('../database');
|
||||
const backup = require('../utils/backup');
|
||||
|
||||
/**
|
||||
* GET /api/health
|
||||
* Einfacher Health-Check
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
// Datenbank-Check
|
||||
const db = getDb();
|
||||
db.prepare('SELECT 1').get();
|
||||
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/detailed
|
||||
* Detaillierter Health-Check (mit Auth)
|
||||
*/
|
||||
router.get('/detailed', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
// Datenbank-Statistiken
|
||||
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
|
||||
const projectCount = db.prepare('SELECT COUNT(*) as count FROM projects WHERE archived = 0').get().count;
|
||||
const taskCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE archived = 0').get().count;
|
||||
|
||||
// Disk-Space für Uploads
|
||||
const uploadsDir = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
|
||||
let uploadsSize = 0;
|
||||
let uploadCount = 0;
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
const getDirectorySize = (dir) => {
|
||||
let size = 0;
|
||||
let count = 0;
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.isDirectory()) {
|
||||
const subResult = getDirectorySize(filePath);
|
||||
size += subResult.size;
|
||||
count += subResult.count;
|
||||
} else {
|
||||
size += stats.size;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return { size, count };
|
||||
};
|
||||
const result = getDirectorySize(uploadsDir);
|
||||
uploadsSize = result.size;
|
||||
uploadCount = result.count;
|
||||
}
|
||||
|
||||
// Letzte Backups
|
||||
const backups = backup.listBackups().slice(0, 5);
|
||||
|
||||
// Memory Usage
|
||||
const memUsage = process.memoryUsage();
|
||||
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: Math.floor(process.uptime()),
|
||||
database: {
|
||||
users: userCount,
|
||||
projects: projectCount,
|
||||
tasks: taskCount
|
||||
},
|
||||
storage: {
|
||||
uploadCount,
|
||||
uploadsSizeMB: Math.round(uploadsSize / 1024 / 1024 * 100) / 100
|
||||
},
|
||||
backups: backups.map(b => ({
|
||||
name: b.name,
|
||||
sizeMB: Math.round(b.size / 1024 / 1024 * 100) / 100,
|
||||
created: b.created
|
||||
})),
|
||||
memory: {
|
||||
heapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024 * 100) / 100,
|
||||
heapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024 * 100) / 100,
|
||||
rssMB: Math.round(memUsage.rss / 1024 / 1024 * 100) / 100
|
||||
},
|
||||
environment: process.env.NODE_ENV || 'development'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(503).json({
|
||||
status: 'unhealthy',
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/health/backup
|
||||
* Manuelles Backup auslösen
|
||||
*/
|
||||
router.post('/backup', (req, res) => {
|
||||
try {
|
||||
const backupPath = backup.createBackup();
|
||||
|
||||
if (backupPath) {
|
||||
res.json({
|
||||
message: 'Backup erfolgreich erstellt',
|
||||
path: path.basename(backupPath)
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ error: 'Backup fehlgeschlagen' });
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/health/backups
|
||||
* Liste aller Backups
|
||||
*/
|
||||
router.get('/backups', (req, res) => {
|
||||
try {
|
||||
const backups = backup.listBackups();
|
||||
|
||||
res.json(backups.map(b => ({
|
||||
name: b.name,
|
||||
sizeMB: Math.round(b.size / 1024 / 1024 * 100) / 100,
|
||||
created: b.created
|
||||
})));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
269
backend/routes/import.js
Normale Datei
269
backend/routes/import.js
Normale Datei
@ -0,0 +1,269 @@
|
||||
/**
|
||||
* TASKMATE - Import Routes
|
||||
* ========================
|
||||
* Import von JSON-Backups
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* POST /api/import/project
|
||||
* Projekt aus JSON importieren
|
||||
*/
|
||||
router.post('/project', (req, res) => {
|
||||
try {
|
||||
const { data, overwrite = false } = req.body;
|
||||
|
||||
if (!data || !data.project) {
|
||||
return res.status(400).json({ error: 'Ungültiges Import-Format' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Transaktion starten
|
||||
const importProject = db.transaction(() => {
|
||||
const importData = data;
|
||||
|
||||
// Projekt erstellen
|
||||
const projectResult = db.prepare(`
|
||||
INSERT INTO projects (name, description, created_by)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(
|
||||
importData.project.name + (overwrite ? '' : ' (Import)'),
|
||||
importData.project.description,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const newProjectId = projectResult.lastInsertRowid;
|
||||
|
||||
// Mapping für alte -> neue IDs
|
||||
const columnMap = new Map();
|
||||
const labelMap = new Map();
|
||||
const taskMap = new Map();
|
||||
|
||||
// Spalten importieren
|
||||
if (importData.columns) {
|
||||
const insertColumn = db.prepare(`
|
||||
INSERT INTO columns (project_id, name, position, color)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
importData.columns.forEach(col => {
|
||||
const result = insertColumn.run(newProjectId, col.name, col.position, col.color);
|
||||
columnMap.set(col.id, result.lastInsertRowid);
|
||||
});
|
||||
}
|
||||
|
||||
// Labels importieren
|
||||
if (importData.labels) {
|
||||
const insertLabel = db.prepare(`
|
||||
INSERT INTO labels (project_id, name, color)
|
||||
VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
importData.labels.forEach(label => {
|
||||
const result = insertLabel.run(newProjectId, label.name, label.color);
|
||||
labelMap.set(label.id, result.lastInsertRowid);
|
||||
});
|
||||
}
|
||||
|
||||
// Aufgaben importieren
|
||||
if (importData.tasks) {
|
||||
const insertTask = db.prepare(`
|
||||
INSERT INTO tasks (
|
||||
project_id, column_id, title, description, priority,
|
||||
due_date, time_estimate_min, position, created_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
importData.tasks.forEach(task => {
|
||||
const newColumnId = columnMap.get(task.column_id);
|
||||
if (!newColumnId) return;
|
||||
|
||||
const result = insertTask.run(
|
||||
newProjectId,
|
||||
newColumnId,
|
||||
task.title,
|
||||
task.description,
|
||||
task.priority || 'medium',
|
||||
task.due_date,
|
||||
task.time_estimate_min,
|
||||
task.position,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
taskMap.set(task.id, result.lastInsertRowid);
|
||||
|
||||
// Task-Labels
|
||||
if (task.labels) {
|
||||
const insertTaskLabel = db.prepare(
|
||||
'INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)'
|
||||
);
|
||||
task.labels.forEach(label => {
|
||||
const newLabelId = labelMap.get(label.id);
|
||||
if (newLabelId) {
|
||||
try {
|
||||
insertTaskLabel.run(result.lastInsertRowid, newLabelId);
|
||||
} catch (e) { /* Ignorieren */ }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Subtasks
|
||||
if (task.subtasks) {
|
||||
const insertSubtask = db.prepare(
|
||||
'INSERT INTO subtasks (task_id, title, completed, position) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
task.subtasks.forEach(st => {
|
||||
insertSubtask.run(
|
||||
result.lastInsertRowid,
|
||||
st.title,
|
||||
st.completed ? 1 : 0,
|
||||
st.position
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Links
|
||||
if (task.links) {
|
||||
const insertLink = db.prepare(
|
||||
'INSERT INTO links (task_id, title, url, created_by) VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
task.links.forEach(link => {
|
||||
insertLink.run(result.lastInsertRowid, link.title, link.url, req.user.id);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Vorlagen importieren
|
||||
if (importData.templates) {
|
||||
const insertTemplate = db.prepare(`
|
||||
INSERT INTO task_templates (
|
||||
project_id, name, title_template, description,
|
||||
priority, labels, subtasks, time_estimate_min
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
importData.templates.forEach(tmpl => {
|
||||
// Labels-IDs mappen
|
||||
let newLabels = null;
|
||||
if (tmpl.labels) {
|
||||
const oldLabels = typeof tmpl.labels === 'string' ? JSON.parse(tmpl.labels) : tmpl.labels;
|
||||
const mappedLabels = oldLabels.map(id => labelMap.get(id)).filter(id => id);
|
||||
newLabels = JSON.stringify(mappedLabels);
|
||||
}
|
||||
|
||||
insertTemplate.run(
|
||||
newProjectId,
|
||||
tmpl.name,
|
||||
tmpl.title_template,
|
||||
tmpl.description,
|
||||
tmpl.priority,
|
||||
newLabels,
|
||||
tmpl.subtasks,
|
||||
tmpl.time_estimate_min
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
projectId: newProjectId,
|
||||
columnsImported: columnMap.size,
|
||||
labelsImported: labelMap.size,
|
||||
tasksImported: taskMap.size
|
||||
};
|
||||
});
|
||||
|
||||
const result = importProject();
|
||||
|
||||
logger.info(`Projekt importiert: ID ${result.projectId} (${result.tasksImported} Aufgaben)`);
|
||||
|
||||
res.status(201).json({
|
||||
message: 'Import erfolgreich',
|
||||
...result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Import:', { error: error.message });
|
||||
res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/import/validate
|
||||
* Import-Datei validieren
|
||||
*/
|
||||
router.post('/validate', (req, res) => {
|
||||
try {
|
||||
const { data } = req.body;
|
||||
|
||||
const errors = [];
|
||||
const warnings = [];
|
||||
|
||||
if (!data) {
|
||||
errors.push('Keine Daten vorhanden');
|
||||
return res.json({ valid: false, errors, warnings });
|
||||
}
|
||||
|
||||
// Version prüfen
|
||||
if (!data.version) {
|
||||
warnings.push('Keine Versionsangabe gefunden');
|
||||
}
|
||||
|
||||
// Projekt prüfen
|
||||
if (!data.project) {
|
||||
errors.push('Kein Projekt in den Daten gefunden');
|
||||
} else {
|
||||
if (!data.project.name) {
|
||||
errors.push('Projektname fehlt');
|
||||
}
|
||||
}
|
||||
|
||||
// Spalten prüfen
|
||||
if (!data.columns || data.columns.length === 0) {
|
||||
errors.push('Keine Spalten in den Daten gefunden');
|
||||
}
|
||||
|
||||
// Aufgaben prüfen
|
||||
if (data.tasks) {
|
||||
data.tasks.forEach((task, idx) => {
|
||||
if (!task.title) {
|
||||
warnings.push(`Aufgabe ${idx + 1} hat keinen Titel`);
|
||||
}
|
||||
if (!task.column_id) {
|
||||
warnings.push(`Aufgabe "${task.title || idx + 1}" hat keine Spalten-ID`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Statistiken
|
||||
const stats = {
|
||||
projectName: data.project?.name || 'Unbekannt',
|
||||
columns: data.columns?.length || 0,
|
||||
labels: data.labels?.length || 0,
|
||||
tasks: data.tasks?.length || 0,
|
||||
templates: data.templates?.length || 0
|
||||
};
|
||||
|
||||
res.json({
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
stats
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Import-Validierung:', { error: error.message });
|
||||
res.status(400).json({
|
||||
valid: false,
|
||||
errors: ['Ungültiges JSON-Format'],
|
||||
warnings: []
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
202
backend/routes/labels.js
Normale Datei
202
backend/routes/labels.js
Normale Datei
@ -0,0 +1,202 @@
|
||||
/**
|
||||
* TASKMATE - Label Routes
|
||||
* =======================
|
||||
* CRUD für Labels/Tags
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators } = require('../middleware/validation');
|
||||
|
||||
/**
|
||||
* GET /api/labels/:projectId
|
||||
* Alle Labels eines Projekts
|
||||
*/
|
||||
router.get('/:projectId', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const labels = db.prepare(`
|
||||
SELECT l.*,
|
||||
(SELECT COUNT(*) FROM task_labels tl WHERE tl.label_id = l.id) as task_count
|
||||
FROM labels l
|
||||
WHERE l.project_id = ?
|
||||
ORDER BY l.name
|
||||
`).all(req.params.projectId);
|
||||
|
||||
res.json(labels.map(l => ({
|
||||
id: l.id,
|
||||
projectId: l.project_id,
|
||||
name: l.name,
|
||||
color: l.color,
|
||||
taskCount: l.task_count
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Labels:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/labels
|
||||
* Neues Label erstellen
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { projectId, name, color } = req.body;
|
||||
|
||||
// Validierung
|
||||
const errors = [];
|
||||
errors.push(validators.required(projectId, 'Projekt-ID'));
|
||||
errors.push(validators.required(name, 'Name'));
|
||||
errors.push(validators.maxLength(name, 30, 'Name'));
|
||||
errors.push(validators.required(color, 'Farbe'));
|
||||
errors.push(validators.hexColor(color, 'Farbe'));
|
||||
|
||||
const firstError = errors.find(e => e !== null);
|
||||
if (firstError) {
|
||||
return res.status(400).json({ error: firstError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prüfen ob Label-Name bereits existiert
|
||||
const existing = db.prepare(
|
||||
'SELECT id FROM labels WHERE project_id = ? AND LOWER(name) = LOWER(?)'
|
||||
).get(projectId, name);
|
||||
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: 'Ein Label mit diesem Namen existiert bereits' });
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO labels (project_id, name, color)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(projectId, name, color);
|
||||
|
||||
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Label erstellt: ${name} in Projekt ${projectId}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${projectId}`).emit('label:created', {
|
||||
id: label.id,
|
||||
projectId: label.project_id,
|
||||
name: label.name,
|
||||
color: label.color
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: label.id,
|
||||
projectId: label.project_id,
|
||||
name: label.name,
|
||||
color: label.color,
|
||||
taskCount: 0
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Labels:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/labels/:id
|
||||
* Label aktualisieren
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const labelId = req.params.id;
|
||||
const { name, color } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (name) {
|
||||
const nameError = validators.maxLength(name, 30, 'Name');
|
||||
if (nameError) return res.status(400).json({ error: nameError });
|
||||
}
|
||||
if (color) {
|
||||
const colorError = validators.hexColor(color, 'Farbe');
|
||||
if (colorError) return res.status(400).json({ error: colorError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM labels WHERE id = ?').get(labelId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Label nicht gefunden' });
|
||||
}
|
||||
|
||||
// Prüfen ob neuer Name bereits existiert
|
||||
if (name && name.toLowerCase() !== existing.name.toLowerCase()) {
|
||||
const duplicate = db.prepare(
|
||||
'SELECT id FROM labels WHERE project_id = ? AND LOWER(name) = LOWER(?) AND id != ?'
|
||||
).get(existing.project_id, name, labelId);
|
||||
|
||||
if (duplicate) {
|
||||
return res.status(400).json({ error: 'Ein Label mit diesem Namen existiert bereits' });
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE labels SET
|
||||
name = COALESCE(?, name),
|
||||
color = COALESCE(?, color)
|
||||
WHERE id = ?
|
||||
`).run(name || null, color || null, labelId);
|
||||
|
||||
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(labelId);
|
||||
|
||||
logger.info(`Label aktualisiert: ${label.name} (ID: ${labelId})`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${label.project_id}`).emit('label:updated', {
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color: label.color
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: label.id,
|
||||
projectId: label.project_id,
|
||||
name: label.name,
|
||||
color: label.color
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren des Labels:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/labels/:id
|
||||
* Label löschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const labelId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const label = db.prepare('SELECT * FROM labels WHERE id = ?').get(labelId);
|
||||
if (!label) {
|
||||
return res.status(404).json({ error: 'Label nicht gefunden' });
|
||||
}
|
||||
|
||||
// Label wird von task_labels durch CASCADE gelöscht
|
||||
db.prepare('DELETE FROM labels WHERE id = ?').run(labelId);
|
||||
|
||||
logger.info(`Label gelöscht: ${label.name} (ID: ${labelId})`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${label.project_id}`).emit('label:deleted', { id: labelId });
|
||||
|
||||
res.json({ message: 'Label gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Labels:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
253
backend/routes/links.js
Normale Datei
253
backend/routes/links.js
Normale Datei
@ -0,0 +1,253 @@
|
||||
/**
|
||||
* TASKMATE - Link Routes
|
||||
* ======================
|
||||
* CRUD für Links/URLs
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators, stripHtml } = require('../middleware/validation');
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: Link-Icon basierend auf URL
|
||||
*/
|
||||
function getLinkIcon(url) {
|
||||
try {
|
||||
const hostname = new URL(url).hostname.toLowerCase();
|
||||
if (hostname.includes('youtube') || hostname.includes('youtu.be')) return 'youtube';
|
||||
if (hostname.includes('github')) return 'github';
|
||||
if (hostname.includes('gitlab')) return 'gitlab';
|
||||
if (hostname.includes('figma')) return 'figma';
|
||||
if (hostname.includes('drive.google')) return 'google-drive';
|
||||
if (hostname.includes('docs.google')) return 'google-docs';
|
||||
if (hostname.includes('notion')) return 'notion';
|
||||
if (hostname.includes('trello')) return 'trello';
|
||||
if (hostname.includes('slack')) return 'slack';
|
||||
if (hostname.includes('jira') || hostname.includes('atlassian')) return 'jira';
|
||||
return 'link';
|
||||
} catch {
|
||||
return 'link';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/links/:taskId
|
||||
* Alle Links einer Aufgabe
|
||||
*/
|
||||
router.get('/:taskId', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const links = db.prepare(`
|
||||
SELECT l.*, u.display_name as creator_name
|
||||
FROM links l
|
||||
LEFT JOIN users u ON l.created_by = u.id
|
||||
WHERE l.task_id = ?
|
||||
ORDER BY l.created_at DESC
|
||||
`).all(req.params.taskId);
|
||||
|
||||
res.json(links.map(l => ({
|
||||
id: l.id,
|
||||
taskId: l.task_id,
|
||||
title: l.title,
|
||||
url: l.url,
|
||||
icon: getLinkIcon(l.url),
|
||||
createdBy: l.created_by,
|
||||
creatorName: l.creator_name,
|
||||
createdAt: l.created_at
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Links:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/links
|
||||
* Neuen Link erstellen
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { taskId, title, url } = req.body;
|
||||
|
||||
// Validierung
|
||||
const urlError = validators.required(url, 'URL') || validators.url(url, 'URL');
|
||||
if (urlError) {
|
||||
return res.status(400).json({ error: urlError });
|
||||
}
|
||||
|
||||
if (title) {
|
||||
const titleError = validators.maxLength(title, 100, 'Titel');
|
||||
if (titleError) {
|
||||
return res.status(400).json({ error: titleError });
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Task prüfen
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
const sanitizedTitle = title ? stripHtml(title) : null;
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO links (task_id, title, url, created_by)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(taskId, sanitizedTitle, url, req.user.id);
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
|
||||
|
||||
const link = db.prepare(`
|
||||
SELECT l.*, u.display_name as creator_name
|
||||
FROM links l
|
||||
LEFT JOIN users u ON l.created_by = u.id
|
||||
WHERE l.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Link erstellt: ${url} für Task ${taskId}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('link:created', {
|
||||
taskId,
|
||||
link: {
|
||||
id: link.id,
|
||||
taskId: link.task_id,
|
||||
title: link.title,
|
||||
url: link.url,
|
||||
icon: getLinkIcon(link.url),
|
||||
createdBy: link.created_by,
|
||||
creatorName: link.creator_name,
|
||||
createdAt: link.created_at
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: link.id,
|
||||
taskId: link.task_id,
|
||||
title: link.title,
|
||||
url: link.url,
|
||||
icon: getLinkIcon(link.url),
|
||||
createdBy: link.created_by,
|
||||
creatorName: link.creator_name,
|
||||
createdAt: link.created_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Links:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/links/:id
|
||||
* Link aktualisieren
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const linkId = req.params.id;
|
||||
const { title, url } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM links WHERE id = ?').get(linkId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Link nicht gefunden' });
|
||||
}
|
||||
|
||||
// Validierung
|
||||
if (url) {
|
||||
const urlError = validators.url(url, 'URL');
|
||||
if (urlError) return res.status(400).json({ error: urlError });
|
||||
}
|
||||
if (title) {
|
||||
const titleError = validators.maxLength(title, 100, 'Titel');
|
||||
if (titleError) return res.status(400).json({ error: titleError });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE links SET
|
||||
title = ?,
|
||||
url = COALESCE(?, url)
|
||||
WHERE id = ?
|
||||
`).run(title !== undefined ? stripHtml(title) : existing.title, url || null, linkId);
|
||||
|
||||
const link = db.prepare(`
|
||||
SELECT l.*, u.display_name as creator_name
|
||||
FROM links l
|
||||
LEFT JOIN users u ON l.created_by = u.id
|
||||
WHERE l.id = ?
|
||||
`).get(linkId);
|
||||
|
||||
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(link.task_id);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('link:updated', {
|
||||
taskId: link.task_id,
|
||||
link: {
|
||||
id: link.id,
|
||||
title: link.title,
|
||||
url: link.url,
|
||||
icon: getLinkIcon(link.url)
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: link.id,
|
||||
taskId: link.task_id,
|
||||
title: link.title,
|
||||
url: link.url,
|
||||
icon: getLinkIcon(link.url),
|
||||
createdBy: link.created_by,
|
||||
creatorName: link.creator_name,
|
||||
createdAt: link.created_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren des Links:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/links/:id
|
||||
* Link löschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const linkId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const link = db.prepare('SELECT * FROM links WHERE id = ?').get(linkId);
|
||||
if (!link) {
|
||||
return res.status(404).json({ error: 'Link nicht gefunden' });
|
||||
}
|
||||
|
||||
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(link.task_id);
|
||||
|
||||
db.prepare('DELETE FROM links WHERE id = ?').run(linkId);
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(link.task_id);
|
||||
|
||||
logger.info(`Link gelöscht: ${link.url}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('link:deleted', {
|
||||
taskId: link.task_id,
|
||||
linkId
|
||||
});
|
||||
|
||||
res.json({ message: 'Link gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Links:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
134
backend/routes/notifications.js
Normale Datei
134
backend/routes/notifications.js
Normale Datei
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* TASKMATE - Notifications Routes
|
||||
* ================================
|
||||
* API-Endpunkte für das Benachrichtigungssystem
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const notificationService = require('../services/notificationService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* GET /api/notifications
|
||||
* Alle Benachrichtigungen des Users abrufen
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const limit = parseInt(req.query.limit) || 50;
|
||||
|
||||
const notifications = notificationService.getForUser(userId, limit);
|
||||
const unreadCount = notificationService.getUnreadCount(userId);
|
||||
|
||||
res.json({
|
||||
notifications,
|
||||
unreadCount
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Benachrichtigungen:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Benachrichtigungen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/notifications/count
|
||||
* Ungelesene Anzahl ermitteln
|
||||
*/
|
||||
router.get('/count', (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const count = notificationService.getUnreadCount(userId);
|
||||
|
||||
res.json({ count });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Ermitteln der Anzahl:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Ermitteln der Anzahl' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/notifications/:id/read
|
||||
* Als gelesen markieren
|
||||
*/
|
||||
router.put('/:id/read', (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const notificationId = parseInt(req.params.id);
|
||||
|
||||
const success = notificationService.markAsRead(notificationId, userId);
|
||||
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Benachrichtigung nicht gefunden' });
|
||||
}
|
||||
|
||||
// Aktualisierte Zählung senden
|
||||
const io = req.app.get('io');
|
||||
const count = notificationService.getUnreadCount(userId);
|
||||
if (io) {
|
||||
io.to(`user:${userId}`).emit('notification:count', { count });
|
||||
}
|
||||
|
||||
res.json({ success: true, unreadCount: count });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Markieren als gelesen:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Markieren als gelesen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/notifications/read-all
|
||||
* Alle als gelesen markieren
|
||||
*/
|
||||
router.put('/read-all', (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
|
||||
const count = notificationService.markAllAsRead(userId);
|
||||
|
||||
// Aktualisierte Zählung senden
|
||||
const io = req.app.get('io');
|
||||
if (io) {
|
||||
io.to(`user:${userId}`).emit('notification:count', { count: 0 });
|
||||
}
|
||||
|
||||
res.json({ success: true, markedCount: count, unreadCount: 0 });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Markieren aller als gelesen:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Markieren aller als gelesen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/notifications/:id
|
||||
* Benachrichtigung löschen (nur nicht-persistente)
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const notificationId = parseInt(req.params.id);
|
||||
|
||||
const success = notificationService.delete(notificationId, userId);
|
||||
|
||||
if (!success) {
|
||||
return res.status(400).json({
|
||||
error: 'Benachrichtigung nicht gefunden oder kann nicht gelöscht werden'
|
||||
});
|
||||
}
|
||||
|
||||
// Aktualisierte Zählung senden
|
||||
const io = req.app.get('io');
|
||||
const count = notificationService.getUnreadCount(userId);
|
||||
if (io) {
|
||||
io.to(`user:${userId}`).emit('notification:count', { count });
|
||||
io.to(`user:${userId}`).emit('notification:deleted', { notificationId });
|
||||
}
|
||||
|
||||
res.json({ success: true, unreadCount: count });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen der Benachrichtigung:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Löschen der Benachrichtigung' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
359
backend/routes/projects.js
Normale Datei
359
backend/routes/projects.js
Normale Datei
@ -0,0 +1,359 @@
|
||||
/**
|
||||
* TASKMATE - Project Routes
|
||||
* =========================
|
||||
* CRUD für Projekte
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators } = require('../middleware/validation');
|
||||
|
||||
/**
|
||||
* GET /api/projects
|
||||
* Alle Projekte abrufen
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const includeArchived = req.query.archived === 'true';
|
||||
|
||||
let query = `
|
||||
SELECT p.*, u.display_name as creator_name,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id AND t.archived = 0) as task_count,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id AND t.archived = 0 AND t.column_id IN
|
||||
(SELECT c.id FROM columns c WHERE c.project_id = p.id ORDER BY c.position DESC LIMIT 1)) as completed_count
|
||||
FROM projects p
|
||||
LEFT JOIN users u ON p.created_by = u.id
|
||||
`;
|
||||
|
||||
if (!includeArchived) {
|
||||
query += ' WHERE p.archived = 0';
|
||||
}
|
||||
|
||||
query += ' ORDER BY p.created_at DESC';
|
||||
|
||||
const projects = db.prepare(query).all();
|
||||
|
||||
res.json(projects.map(p => ({
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
archived: !!p.archived,
|
||||
createdAt: p.created_at,
|
||||
createdBy: p.created_by,
|
||||
creatorName: p.creator_name,
|
||||
taskCount: p.task_count,
|
||||
completedCount: p.completed_count
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Projekte:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/projects/:id
|
||||
* Einzelnes Projekt mit Spalten und Aufgaben
|
||||
*/
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const projectId = req.params.id;
|
||||
|
||||
// Projekt abrufen
|
||||
const project = db.prepare(`
|
||||
SELECT p.*, u.display_name as creator_name
|
||||
FROM projects p
|
||||
LEFT JOIN users u ON p.created_by = u.id
|
||||
WHERE p.id = ?
|
||||
`).get(projectId);
|
||||
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' });
|
||||
}
|
||||
|
||||
// Spalten abrufen
|
||||
const columns = db.prepare(`
|
||||
SELECT * FROM columns WHERE project_id = ? ORDER BY position
|
||||
`).all(projectId);
|
||||
|
||||
// Labels abrufen
|
||||
const labels = db.prepare(`
|
||||
SELECT * FROM labels WHERE project_id = ?
|
||||
`).all(projectId);
|
||||
|
||||
res.json({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
archived: !!project.archived,
|
||||
createdAt: project.created_at,
|
||||
createdBy: project.created_by,
|
||||
creatorName: project.creator_name,
|
||||
columns: columns.map(c => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
position: c.position,
|
||||
color: c.color
|
||||
})),
|
||||
labels: labels.map(l => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
color: l.color
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des Projekts:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/projects
|
||||
* Neues Projekt erstellen
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { name, description } = req.body;
|
||||
|
||||
// Validierung
|
||||
const nameError = validators.required(name, 'Name') ||
|
||||
validators.maxLength(name, 100, 'Name');
|
||||
if (nameError) {
|
||||
return res.status(400).json({ error: nameError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Projekt erstellen
|
||||
const result = db.prepare(`
|
||||
INSERT INTO projects (name, description, created_by)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(name, description || null, req.user.id);
|
||||
|
||||
const projectId = result.lastInsertRowid;
|
||||
|
||||
// Standard-Spalten erstellen
|
||||
const insertColumn = db.prepare(`
|
||||
INSERT INTO columns (project_id, name, position) VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
insertColumn.run(projectId, 'Offen', 0);
|
||||
insertColumn.run(projectId, 'In Arbeit', 1);
|
||||
insertColumn.run(projectId, 'Erledigt', 2);
|
||||
|
||||
// Standard-Labels erstellen
|
||||
const insertLabel = db.prepare(`
|
||||
INSERT INTO labels (project_id, name, color) VALUES (?, ?, ?)
|
||||
`);
|
||||
|
||||
insertLabel.run(projectId, 'Bug', '#DC2626');
|
||||
insertLabel.run(projectId, 'Feature', '#059669');
|
||||
insertLabel.run(projectId, 'Dokumentation', '#3182CE');
|
||||
|
||||
// Projekt mit Spalten und Labels zurückgeben
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
|
||||
const columns = db.prepare('SELECT * FROM columns WHERE project_id = ? ORDER BY position').all(projectId);
|
||||
const labels = db.prepare('SELECT * FROM labels WHERE project_id = ?').all(projectId);
|
||||
|
||||
logger.info(`Projekt erstellt: ${name} (ID: ${projectId}) von ${req.user.username}`);
|
||||
|
||||
// WebSocket: Andere Clients benachrichtigen
|
||||
const io = req.app.get('io');
|
||||
io.emit('project:created', {
|
||||
id: projectId,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
createdBy: req.user.id
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
archived: false,
|
||||
createdAt: project.created_at,
|
||||
createdBy: project.created_by,
|
||||
columns: columns.map(c => ({ id: c.id, name: c.name, position: c.position, color: c.color })),
|
||||
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color }))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Projekts:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/projects/:id
|
||||
* Projekt aktualisieren
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const projectId = req.params.id;
|
||||
const { name, description } = req.body;
|
||||
|
||||
// Validierung
|
||||
if (name) {
|
||||
const nameError = validators.maxLength(name, 100, 'Name');
|
||||
if (nameError) {
|
||||
return res.status(400).json({ error: nameError });
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Prüfen ob Projekt existiert
|
||||
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' });
|
||||
}
|
||||
|
||||
// Aktualisieren
|
||||
db.prepare(`
|
||||
UPDATE projects
|
||||
SET name = COALESCE(?, name), description = COALESCE(?, description)
|
||||
WHERE id = ?
|
||||
`).run(name || null, description !== undefined ? description : null, projectId);
|
||||
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
|
||||
|
||||
logger.info(`Projekt aktualisiert: ${project.name} (ID: ${projectId})`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.emit('project:updated', {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
description: project.description,
|
||||
archived: !!project.archived,
|
||||
createdAt: project.created_at
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren des Projekts:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/projects/:id/archive
|
||||
* Projekt archivieren/wiederherstellen
|
||||
*/
|
||||
router.put('/:id/archive', (req, res) => {
|
||||
try {
|
||||
const projectId = req.params.id;
|
||||
const { archived } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE projects SET archived = ? WHERE id = ?')
|
||||
.run(archived ? 1 : 0, projectId);
|
||||
|
||||
logger.info(`Projekt ${archived ? 'archiviert' : 'wiederhergestellt'}: ${existing.name}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.emit('project:archived', { id: projectId, archived: !!archived });
|
||||
|
||||
res.json({ message: archived ? 'Projekt archiviert' : 'Projekt wiederhergestellt' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Archivieren:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/projects/:id
|
||||
* Projekt löschen
|
||||
* Query param: force=true um alle zugehörigen Aufgaben mitzulöschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const projectId = req.params.id;
|
||||
const forceDelete = req.query.force === 'true';
|
||||
const db = getDb();
|
||||
|
||||
const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
|
||||
if (!project) {
|
||||
return res.status(404).json({ error: 'Projekt nicht gefunden' });
|
||||
}
|
||||
|
||||
// Anzahl der Aufgaben ermitteln
|
||||
const taskCount = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM tasks WHERE project_id = ?'
|
||||
).get(projectId).count;
|
||||
|
||||
// Ohne force: Prüfen ob noch aktive Aufgaben existieren
|
||||
if (!forceDelete && taskCount > 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Projekt enthält noch Aufgaben. Verwende force=true um alles zu löschen.',
|
||||
taskCount: taskCount
|
||||
});
|
||||
}
|
||||
|
||||
// Bei force=true: Explizit alle zugehörigen Daten löschen
|
||||
if (forceDelete && taskCount > 0) {
|
||||
// Alle Task-IDs für das Projekt holen
|
||||
const taskIds = db.prepare('SELECT id FROM tasks WHERE project_id = ?')
|
||||
.all(projectId)
|
||||
.map(t => t.id);
|
||||
|
||||
if (taskIds.length > 0) {
|
||||
const placeholders = taskIds.map(() => '?').join(',');
|
||||
|
||||
// Anhänge löschen
|
||||
db.prepare(`DELETE FROM attachments WHERE task_id IN (${placeholders})`).run(...taskIds);
|
||||
// Kommentare löschen
|
||||
db.prepare(`DELETE FROM comments WHERE task_id IN (${placeholders})`).run(...taskIds);
|
||||
// Task-Labels löschen
|
||||
db.prepare(`DELETE FROM task_labels WHERE task_id IN (${placeholders})`).run(...taskIds);
|
||||
// Task-Assignees löschen (Mehrfachzuweisung)
|
||||
db.prepare(`DELETE FROM task_assignees WHERE task_id IN (${placeholders})`).run(...taskIds);
|
||||
// Unteraufgaben löschen
|
||||
db.prepare(`DELETE FROM subtasks WHERE task_id IN (${placeholders})`).run(...taskIds);
|
||||
// Links löschen
|
||||
db.prepare(`DELETE FROM links WHERE task_id IN (${placeholders})`).run(...taskIds);
|
||||
// Historie löschen
|
||||
db.prepare(`DELETE FROM history WHERE task_id IN (${placeholders})`).run(...taskIds);
|
||||
// Tasks löschen
|
||||
db.prepare(`DELETE FROM tasks WHERE project_id = ?`).run(projectId);
|
||||
}
|
||||
|
||||
logger.info(`${taskCount} Aufgaben gelöscht für Projekt: ${project.name}`);
|
||||
}
|
||||
|
||||
// Labels des Projekts löschen
|
||||
db.prepare('DELETE FROM labels WHERE project_id = ?').run(projectId);
|
||||
|
||||
// Spalten löschen
|
||||
db.prepare('DELETE FROM columns WHERE project_id = ?').run(projectId);
|
||||
|
||||
// Projekt löschen
|
||||
db.prepare('DELETE FROM projects WHERE id = ?').run(projectId);
|
||||
|
||||
logger.info(`Projekt gelöscht: ${project.name} (ID: ${projectId}), ${taskCount} Aufgaben entfernt`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.emit('project:deleted', { id: projectId });
|
||||
|
||||
res.json({ message: 'Projekt gelöscht', deletedTasks: taskCount });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Projekts:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
299
backend/routes/proposals.js
Normale Datei
299
backend/routes/proposals.js
Normale Datei
@ -0,0 +1,299 @@
|
||||
/**
|
||||
* TASKMATE - Proposals Routes
|
||||
* ===========================
|
||||
* API-Endpunkte fuer Vorschlaege und Genehmigungen
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const { authenticateToken, requireRegularUser, checkPermission } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
const notificationService = require('../services/notificationService');
|
||||
|
||||
// Alle Proposals-Routes erfordern Authentifizierung und regulaeren User (kein Admin)
|
||||
router.use(authenticateToken);
|
||||
router.use(requireRegularUser);
|
||||
|
||||
/**
|
||||
* GET /api/proposals - Alle Genehmigungen abrufen (projektbezogen)
|
||||
* Query-Parameter: sort = 'date' | 'alpha', archived = '0' | '1', projectId = number
|
||||
*/
|
||||
router.get('/', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const sort = req.query.sort || 'date';
|
||||
const archived = req.query.archived === '1' ? 1 : 0;
|
||||
const projectId = req.query.projectId ? parseInt(req.query.projectId) : null;
|
||||
|
||||
let orderBy;
|
||||
switch (sort) {
|
||||
case 'alpha':
|
||||
orderBy = 'p.title ASC';
|
||||
break;
|
||||
case 'date':
|
||||
default:
|
||||
orderBy = 'p.created_at DESC';
|
||||
break;
|
||||
}
|
||||
|
||||
// Nur Genehmigungen des aktuellen Projekts laden
|
||||
let whereClause = 'p.archived = ?';
|
||||
const params = [archived];
|
||||
|
||||
if (projectId) {
|
||||
whereClause += ' AND p.project_id = ?';
|
||||
params.push(projectId);
|
||||
}
|
||||
|
||||
const proposals = db.prepare(`
|
||||
SELECT
|
||||
p.*,
|
||||
u.display_name as created_by_name,
|
||||
u.color as created_by_color,
|
||||
ua.display_name as approved_by_name,
|
||||
t.title as task_title,
|
||||
t.id as linked_task_id
|
||||
FROM proposals p
|
||||
LEFT JOIN users u ON p.created_by = u.id
|
||||
LEFT JOIN users ua ON p.approved_by = ua.id
|
||||
LEFT JOIN tasks t ON p.task_id = t.id
|
||||
WHERE ${whereClause}
|
||||
ORDER BY ${orderBy}
|
||||
`).all(...params);
|
||||
|
||||
res.json(proposals);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Genehmigungen:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Abrufen der Genehmigungen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/proposals - Neue Genehmigung erstellen (projektbezogen)
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { title, description, taskId, projectId } = req.body;
|
||||
|
||||
if (!title || title.trim().length === 0) {
|
||||
return res.status(400).json({ error: 'Titel erforderlich' });
|
||||
}
|
||||
|
||||
if (!projectId) {
|
||||
return res.status(400).json({ error: 'Projekt erforderlich' });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO proposals (title, description, created_by, task_id, project_id)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`).run(title.trim(), description?.trim() || null, req.user.id, taskId || null, projectId);
|
||||
|
||||
const proposal = db.prepare(`
|
||||
SELECT
|
||||
p.*,
|
||||
u.display_name as created_by_name,
|
||||
u.color as created_by_color,
|
||||
t.title as task_title,
|
||||
t.id as linked_task_id
|
||||
FROM proposals p
|
||||
LEFT JOIN users u ON p.created_by = u.id
|
||||
LEFT JOIN tasks t ON p.task_id = t.id
|
||||
WHERE p.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Benutzer ${req.user.username} hat Genehmigung "${title}" erstellt`);
|
||||
|
||||
// Benachrichtigungen an User mit 'genehmigung'-Berechtigung senden (persistent)
|
||||
const io = req.app.get('io');
|
||||
const usersWithPermission = db.prepare(`
|
||||
SELECT id FROM users
|
||||
WHERE role = 'user'
|
||||
AND permissions LIKE '%genehmigung%'
|
||||
AND id != ?
|
||||
`).all(req.user.id);
|
||||
|
||||
usersWithPermission.forEach(user => {
|
||||
notificationService.create(user.id, 'approval:pending', {
|
||||
proposalId: proposal.id,
|
||||
proposalTitle: title.trim(),
|
||||
projectId: projectId,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io, true); // persistent = true
|
||||
});
|
||||
|
||||
res.status(201).json(proposal);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen der Genehmigung:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Erstellen der Genehmigung' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/proposals/:id/approve - Genehmigung erteilen (nur mit Berechtigung)
|
||||
*/
|
||||
router.put('/:id/approve', checkPermission('genehmigung'), (req, res) => {
|
||||
try {
|
||||
const proposalId = parseInt(req.params.id);
|
||||
const { approved } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
// Genehmigung pruefen
|
||||
const proposal = db.prepare('SELECT * FROM proposals WHERE id = ?').get(proposalId);
|
||||
if (!proposal) {
|
||||
return res.status(404).json({ error: 'Genehmigung nicht gefunden' });
|
||||
}
|
||||
|
||||
if (approved) {
|
||||
// Genehmigen
|
||||
db.prepare(`
|
||||
UPDATE proposals
|
||||
SET approved = 1, approved_by = ?, approved_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(req.user.id, proposalId);
|
||||
logger.info(`Benutzer ${req.user.username} hat Genehmigung ${proposalId} erteilt`);
|
||||
} else {
|
||||
// Genehmigung zurueckziehen
|
||||
db.prepare(`
|
||||
UPDATE proposals
|
||||
SET approved = 0, approved_by = NULL, approved_at = NULL
|
||||
WHERE id = ?
|
||||
`).run(proposalId);
|
||||
logger.info(`Benutzer ${req.user.username} hat Genehmigung ${proposalId} zurueckgezogen`);
|
||||
}
|
||||
|
||||
// Aktualisierte Genehmigung zurueckgeben
|
||||
const updatedProposal = db.prepare(`
|
||||
SELECT
|
||||
p.*,
|
||||
u.display_name as created_by_name,
|
||||
u.color as created_by_color,
|
||||
ua.display_name as approved_by_name,
|
||||
t.title as task_title,
|
||||
t.id as linked_task_id
|
||||
FROM proposals p
|
||||
LEFT JOIN users u ON p.created_by = u.id
|
||||
LEFT JOIN users ua ON p.approved_by = ua.id
|
||||
LEFT JOIN tasks t ON p.task_id = t.id
|
||||
WHERE p.id = ?
|
||||
`).get(proposalId);
|
||||
|
||||
// Benachrichtigungen senden
|
||||
const io = req.app.get('io');
|
||||
|
||||
if (approved) {
|
||||
// Ersteller benachrichtigen dass genehmigt wurde
|
||||
if (proposal.created_by !== req.user.id) {
|
||||
notificationService.create(proposal.created_by, 'approval:granted', {
|
||||
proposalId: proposalId,
|
||||
proposalTitle: proposal.title,
|
||||
projectId: proposal.project_id,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
|
||||
// Persistente Benachrichtigungen auflösen
|
||||
notificationService.resolvePersistent(proposalId);
|
||||
|
||||
// Aktualisierte Zählung an alle User mit Berechtigung senden
|
||||
const usersWithPermission = db.prepare(`
|
||||
SELECT id FROM users
|
||||
WHERE role = 'user'
|
||||
AND permissions LIKE '%genehmigung%'
|
||||
`).all();
|
||||
|
||||
usersWithPermission.forEach(user => {
|
||||
const count = notificationService.getUnreadCount(user.id);
|
||||
io.to(`user:${user.id}`).emit('notification:count', { count });
|
||||
});
|
||||
}
|
||||
|
||||
res.json(updatedProposal);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Genehmigen:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Genehmigen' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/proposals/:id/archive - Genehmigung archivieren/wiederherstellen (nur mit Berechtigung)
|
||||
*/
|
||||
router.put('/:id/archive', checkPermission('genehmigung'), (req, res) => {
|
||||
try {
|
||||
const proposalId = parseInt(req.params.id);
|
||||
const { archived } = req.body;
|
||||
const db = getDb();
|
||||
|
||||
// Genehmigung pruefen
|
||||
const proposal = db.prepare('SELECT * FROM proposals WHERE id = ?').get(proposalId);
|
||||
if (!proposal) {
|
||||
return res.status(404).json({ error: 'Genehmigung nicht gefunden' });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE proposals
|
||||
SET archived = ?
|
||||
WHERE id = ?
|
||||
`).run(archived ? 1 : 0, proposalId);
|
||||
|
||||
logger.info(`Benutzer ${req.user.username} hat Genehmigung ${proposalId} ${archived ? 'archiviert' : 'wiederhergestellt'}`);
|
||||
|
||||
// Aktualisierte Genehmigung zurueckgeben
|
||||
const updatedProposal = db.prepare(`
|
||||
SELECT
|
||||
p.*,
|
||||
u.display_name as created_by_name,
|
||||
u.color as created_by_color,
|
||||
ua.display_name as approved_by_name,
|
||||
t.title as task_title,
|
||||
t.id as linked_task_id
|
||||
FROM proposals p
|
||||
LEFT JOIN users u ON p.created_by = u.id
|
||||
LEFT JOIN users ua ON p.approved_by = ua.id
|
||||
LEFT JOIN tasks t ON p.task_id = t.id
|
||||
WHERE p.id = ?
|
||||
`).get(proposalId);
|
||||
|
||||
res.json(updatedProposal);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Archivieren:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Archivieren' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/proposals/:id - Eigene Genehmigung loeschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const proposalId = parseInt(req.params.id);
|
||||
const db = getDb();
|
||||
|
||||
// Genehmigung pruefen
|
||||
const proposal = db.prepare('SELECT * FROM proposals WHERE id = ?').get(proposalId);
|
||||
if (!proposal) {
|
||||
return res.status(404).json({ error: 'Genehmigung nicht gefunden' });
|
||||
}
|
||||
|
||||
// Nur eigene Genehmigungen loeschen (oder mit genehmigung-Berechtigung)
|
||||
const permissions = req.user.permissions || [];
|
||||
if (proposal.created_by !== req.user.id && !permissions.includes('genehmigung')) {
|
||||
return res.status(403).json({ error: 'Nur eigene Genehmigungen koennen geloescht werden' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM proposals WHERE id = ?').run(proposalId);
|
||||
|
||||
logger.info(`Benutzer ${req.user.username} hat Genehmigung ${proposalId} geloescht`);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Loeschen der Genehmigung:', error);
|
||||
res.status(500).json({ error: 'Fehler beim Loeschen der Genehmigung' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
310
backend/routes/stats.js
Normale Datei
310
backend/routes/stats.js
Normale Datei
@ -0,0 +1,310 @@
|
||||
/**
|
||||
* TASKMATE - Stats Routes
|
||||
* =======================
|
||||
* Dashboard-Statistiken
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* GET /api/stats/dashboard
|
||||
* Haupt-Dashboard Statistiken
|
||||
*/
|
||||
router.get('/dashboard', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const { projectId } = req.query;
|
||||
|
||||
let projectFilter = '';
|
||||
const params = [];
|
||||
|
||||
if (projectId) {
|
||||
projectFilter = ' AND t.project_id = ?';
|
||||
params.push(projectId);
|
||||
}
|
||||
|
||||
// Gesamtzahlen
|
||||
const total = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM tasks t
|
||||
WHERE t.archived = 0 ${projectFilter}
|
||||
`).get(...params).count;
|
||||
|
||||
// Offene Aufgaben (erste Spalte jedes Projekts)
|
||||
const open = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM tasks t
|
||||
JOIN columns c ON t.column_id = c.id
|
||||
WHERE t.archived = 0 AND c.position = 0 ${projectFilter}
|
||||
`).get(...params).count;
|
||||
|
||||
// In Arbeit (mittlere Spalten)
|
||||
const inProgress = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM tasks t
|
||||
JOIN columns c ON t.column_id = c.id
|
||||
WHERE t.archived = 0 AND c.position > 0
|
||||
AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
|
||||
${projectFilter}
|
||||
`).get(...params).count;
|
||||
|
||||
// Erledigt (letzte Spalte)
|
||||
const completed = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM tasks t
|
||||
JOIN columns c ON t.column_id = c.id
|
||||
WHERE t.archived = 0
|
||||
AND c.position = (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
|
||||
${projectFilter}
|
||||
`).get(...params).count;
|
||||
|
||||
// Überfällig
|
||||
const overdue = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM tasks t
|
||||
JOIN columns c ON t.column_id = c.id
|
||||
WHERE t.archived = 0
|
||||
AND t.due_date < date('now')
|
||||
AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
|
||||
${projectFilter}
|
||||
`).get(...params).count;
|
||||
|
||||
// Heute fällig
|
||||
const dueToday = db.prepare(`
|
||||
SELECT t.id, t.title, t.priority, t.assigned_to,
|
||||
u.display_name as assigned_name, u.color as assigned_color
|
||||
FROM tasks t
|
||||
LEFT JOIN users u ON t.assigned_to = u.id
|
||||
JOIN columns c ON t.column_id = c.id
|
||||
WHERE t.archived = 0
|
||||
AND t.due_date = date('now')
|
||||
AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
|
||||
${projectFilter}
|
||||
ORDER BY t.priority DESC
|
||||
LIMIT 10
|
||||
`).all(...params);
|
||||
|
||||
// Bald fällig (nächste 7 Tage)
|
||||
const dueSoon = db.prepare(`
|
||||
SELECT COUNT(*) as count FROM tasks t
|
||||
JOIN columns c ON t.column_id = c.id
|
||||
WHERE t.archived = 0
|
||||
AND t.due_date BETWEEN date('now', '+1 day') AND date('now', '+7 days')
|
||||
AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
|
||||
${projectFilter}
|
||||
`).get(...params).count;
|
||||
|
||||
res.json({
|
||||
total,
|
||||
open,
|
||||
inProgress,
|
||||
completed,
|
||||
overdue,
|
||||
dueSoon,
|
||||
dueToday: dueToday.map(t => ({
|
||||
id: t.id,
|
||||
title: t.title,
|
||||
priority: t.priority,
|
||||
assignedTo: t.assigned_to,
|
||||
assignedName: t.assigned_name,
|
||||
assignedColor: t.assigned_color
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Dashboard-Stats:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/stats/completed-per-week
|
||||
* Erledigte Aufgaben pro Woche
|
||||
*/
|
||||
router.get('/completed-per-week', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const { projectId, weeks = 8 } = req.query;
|
||||
|
||||
let projectFilter = '';
|
||||
const params = [parseInt(weeks)];
|
||||
|
||||
if (projectId) {
|
||||
projectFilter = ' AND h.task_id IN (SELECT id FROM tasks WHERE project_id = ?)';
|
||||
params.push(projectId);
|
||||
}
|
||||
|
||||
// Erledigte Aufgaben pro Kalenderwoche
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
strftime('%Y-%W', h.timestamp) as week,
|
||||
COUNT(DISTINCT h.task_id) as count
|
||||
FROM history h
|
||||
WHERE h.action = 'moved'
|
||||
AND h.new_value IN (
|
||||
SELECT name FROM columns c
|
||||
WHERE c.position = (SELECT MAX(position) FROM columns WHERE project_id = c.project_id)
|
||||
)
|
||||
AND h.timestamp >= date('now', '-' || ? || ' weeks')
|
||||
${projectFilter}
|
||||
GROUP BY week
|
||||
ORDER BY week DESC
|
||||
`).all(...params);
|
||||
|
||||
// Letzten X Wochen mit 0 auffüllen
|
||||
const result = [];
|
||||
const now = new Date();
|
||||
|
||||
for (let i = 0; i < parseInt(weeks); i++) {
|
||||
const date = new Date(now);
|
||||
date.setDate(date.getDate() - (i * 7));
|
||||
const year = date.getFullYear();
|
||||
const week = getWeekNumber(date);
|
||||
const weekKey = `${year}-${week.toString().padStart(2, '0')}`;
|
||||
|
||||
const found = stats.find(s => s.week === weekKey);
|
||||
result.unshift({
|
||||
week: weekKey,
|
||||
label: `KW${week}`,
|
||||
count: found ? found.count : 0
|
||||
});
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Completed-per-Week Stats:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/stats/time-per-project
|
||||
* Geschätzte Zeit pro Projekt
|
||||
*/
|
||||
router.get('/time-per-project', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
p.id,
|
||||
p.name,
|
||||
COALESCE(SUM(t.time_estimate_min), 0) as total_minutes,
|
||||
COUNT(t.id) as task_count
|
||||
FROM projects p
|
||||
LEFT JOIN tasks t ON p.id = t.project_id AND t.archived = 0
|
||||
WHERE p.archived = 0
|
||||
GROUP BY p.id
|
||||
ORDER BY total_minutes DESC
|
||||
`).all();
|
||||
|
||||
res.json(stats.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
totalMinutes: s.total_minutes,
|
||||
totalHours: Math.round(s.total_minutes / 60 * 10) / 10,
|
||||
taskCount: s.task_count
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Time-per-Project Stats:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/stats/user-activity
|
||||
* Aktivität pro Benutzer
|
||||
*/
|
||||
router.get('/user-activity', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const { days = 30 } = req.query;
|
||||
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
u.id,
|
||||
u.display_name,
|
||||
u.color,
|
||||
COUNT(DISTINCT CASE WHEN h.action = 'created' THEN h.task_id END) as tasks_created,
|
||||
COUNT(DISTINCT CASE WHEN h.action = 'moved' THEN h.task_id END) as tasks_moved,
|
||||
COUNT(DISTINCT CASE WHEN h.action = 'commented' THEN h.id END) as comments,
|
||||
COUNT(h.id) as total_actions
|
||||
FROM users u
|
||||
LEFT JOIN history h ON u.id = h.user_id AND h.timestamp >= date('now', '-' || ? || ' days')
|
||||
GROUP BY u.id
|
||||
ORDER BY total_actions DESC
|
||||
`).all(parseInt(days));
|
||||
|
||||
res.json(stats.map(s => ({
|
||||
id: s.id,
|
||||
displayName: s.display_name,
|
||||
color: s.color,
|
||||
tasksCreated: s.tasks_created,
|
||||
tasksMoved: s.tasks_moved,
|
||||
comments: s.comments,
|
||||
totalActions: s.total_actions
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei User-Activity Stats:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/stats/calendar
|
||||
* Aufgaben nach Datum (für Kalender)
|
||||
*/
|
||||
router.get('/calendar', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const { projectId, month, year } = req.query;
|
||||
|
||||
const currentYear = year || new Date().getFullYear();
|
||||
const currentMonth = month || (new Date().getMonth() + 1);
|
||||
|
||||
// Start und Ende des Monats
|
||||
const startDate = `${currentYear}-${currentMonth.toString().padStart(2, '0')}-01`;
|
||||
const endDate = `${currentYear}-${currentMonth.toString().padStart(2, '0')}-31`;
|
||||
|
||||
let query = `
|
||||
SELECT
|
||||
t.due_date,
|
||||
COUNT(*) as count,
|
||||
SUM(CASE WHEN t.due_date < date('now') AND c.position < (SELECT MAX(position) FROM columns WHERE project_id = c.project_id) THEN 1 ELSE 0 END) as overdue_count
|
||||
FROM tasks t
|
||||
JOIN columns c ON t.column_id = c.id
|
||||
WHERE t.archived = 0
|
||||
AND t.due_date BETWEEN ? AND ?
|
||||
`;
|
||||
|
||||
const params = [startDate, endDate];
|
||||
|
||||
if (projectId) {
|
||||
query += ' AND t.project_id = ?';
|
||||
params.push(projectId);
|
||||
}
|
||||
|
||||
query += ' GROUP BY t.due_date ORDER BY t.due_date';
|
||||
|
||||
const stats = db.prepare(query).all(...params);
|
||||
|
||||
res.json(stats.map(s => ({
|
||||
date: s.due_date,
|
||||
count: s.count,
|
||||
overdueCount: s.overdue_count
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei Calendar Stats:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: Kalenderwoche berechnen
|
||||
*/
|
||||
function getWeekNumber(date) {
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||||
}
|
||||
|
||||
module.exports = router;
|
||||
279
backend/routes/subtasks.js
Normale Datei
279
backend/routes/subtasks.js
Normale Datei
@ -0,0 +1,279 @@
|
||||
/**
|
||||
* TASKMATE - Subtask Routes
|
||||
* =========================
|
||||
* CRUD für Unteraufgaben/Checkliste
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators } = require('../middleware/validation');
|
||||
|
||||
/**
|
||||
* GET /api/subtasks/:taskId
|
||||
* Alle Unteraufgaben einer Aufgabe
|
||||
*/
|
||||
router.get('/:taskId', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const subtasks = db.prepare(`
|
||||
SELECT * FROM subtasks WHERE task_id = ? ORDER BY position
|
||||
`).all(req.params.taskId);
|
||||
|
||||
res.json(subtasks.map(s => ({
|
||||
id: s.id,
|
||||
taskId: s.task_id,
|
||||
title: s.title,
|
||||
completed: !!s.completed,
|
||||
position: s.position
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Subtasks:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/subtasks
|
||||
* Neue Unteraufgabe erstellen
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const { taskId, title } = req.body;
|
||||
|
||||
// Validierung
|
||||
const titleError = validators.required(title, 'Titel') ||
|
||||
validators.maxLength(title, 200, 'Titel');
|
||||
if (titleError) {
|
||||
return res.status(400).json({ error: titleError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Task prüfen
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
// Höchste Position ermitteln
|
||||
const maxPos = db.prepare(
|
||||
'SELECT COALESCE(MAX(position), -1) as max FROM subtasks WHERE task_id = ?'
|
||||
).get(taskId).max;
|
||||
|
||||
// Subtask erstellen
|
||||
const result = db.prepare(`
|
||||
INSERT INTO subtasks (task_id, title, position)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(taskId, title, maxPos + 1);
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
|
||||
|
||||
const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Subtask erstellt: ${title} in Task ${taskId}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('subtask:created', {
|
||||
taskId,
|
||||
subtask: {
|
||||
id: subtask.id,
|
||||
taskId: subtask.task_id,
|
||||
title: subtask.title,
|
||||
completed: false,
|
||||
position: subtask.position
|
||||
}
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: subtask.id,
|
||||
taskId: subtask.task_id,
|
||||
title: subtask.title,
|
||||
completed: false,
|
||||
position: subtask.position
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Subtasks:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/subtasks/:id
|
||||
* Unteraufgabe aktualisieren
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const subtaskId = req.params.id;
|
||||
const { title, completed } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(subtaskId);
|
||||
if (!subtask) {
|
||||
return res.status(404).json({ error: 'Unteraufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
// Validierung
|
||||
if (title) {
|
||||
const titleError = validators.maxLength(title, 200, 'Titel');
|
||||
if (titleError) {
|
||||
return res.status(400).json({ error: titleError });
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE subtasks SET
|
||||
title = COALESCE(?, title),
|
||||
completed = COALESCE(?, completed)
|
||||
WHERE id = ?
|
||||
`).run(title || null, completed !== undefined ? (completed ? 1 : 0) : null, subtaskId);
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(subtask.task_id);
|
||||
|
||||
const updated = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(subtaskId);
|
||||
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(subtask.task_id);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('subtask:updated', {
|
||||
taskId: subtask.task_id,
|
||||
subtask: {
|
||||
id: updated.id,
|
||||
taskId: updated.task_id,
|
||||
title: updated.title,
|
||||
completed: !!updated.completed,
|
||||
position: updated.position
|
||||
}
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: updated.id,
|
||||
taskId: updated.task_id,
|
||||
title: updated.title,
|
||||
completed: !!updated.completed,
|
||||
position: updated.position
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren des Subtasks:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/subtasks/:id/position
|
||||
* Unteraufgabe-Position ändern
|
||||
*/
|
||||
router.put('/:id/position', (req, res) => {
|
||||
try {
|
||||
const subtaskId = req.params.id;
|
||||
const { newPosition } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(subtaskId);
|
||||
if (!subtask) {
|
||||
return res.status(404).json({ error: 'Unteraufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
const oldPosition = subtask.position;
|
||||
const taskId = subtask.task_id;
|
||||
|
||||
if (newPosition > oldPosition) {
|
||||
db.prepare(`
|
||||
UPDATE subtasks SET position = position - 1
|
||||
WHERE task_id = ? AND position > ? AND position <= ?
|
||||
`).run(taskId, oldPosition, newPosition);
|
||||
} else if (newPosition < oldPosition) {
|
||||
db.prepare(`
|
||||
UPDATE subtasks SET position = position + 1
|
||||
WHERE task_id = ? AND position >= ? AND position < ?
|
||||
`).run(taskId, newPosition, oldPosition);
|
||||
}
|
||||
|
||||
db.prepare('UPDATE subtasks SET position = ? WHERE id = ?').run(newPosition, subtaskId);
|
||||
|
||||
const subtasks = db.prepare(
|
||||
'SELECT * FROM subtasks WHERE task_id = ? ORDER BY position'
|
||||
).all(taskId);
|
||||
|
||||
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(taskId);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('subtasks:reordered', {
|
||||
taskId,
|
||||
subtasks: subtasks.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
completed: !!s.completed,
|
||||
position: s.position
|
||||
}))
|
||||
});
|
||||
|
||||
res.json({
|
||||
subtasks: subtasks.map(s => ({
|
||||
id: s.id,
|
||||
taskId: s.task_id,
|
||||
title: s.title,
|
||||
completed: !!s.completed,
|
||||
position: s.position
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Verschieben des Subtasks:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/subtasks/:id
|
||||
* Unteraufgabe löschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const subtaskId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const subtask = db.prepare('SELECT * FROM subtasks WHERE id = ?').get(subtaskId);
|
||||
if (!subtask) {
|
||||
return res.status(404).json({ error: 'Unteraufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
const taskId = subtask.task_id;
|
||||
|
||||
db.prepare('DELETE FROM subtasks WHERE id = ?').run(subtaskId);
|
||||
|
||||
// Positionen neu nummerieren
|
||||
const remaining = db.prepare(
|
||||
'SELECT id FROM subtasks WHERE task_id = ? ORDER BY position'
|
||||
).all(taskId);
|
||||
|
||||
remaining.forEach((s, idx) => {
|
||||
db.prepare('UPDATE subtasks SET position = ? WHERE id = ?').run(idx, s.id);
|
||||
});
|
||||
|
||||
// Task updated_at aktualisieren
|
||||
db.prepare('UPDATE tasks SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(taskId);
|
||||
|
||||
const task = db.prepare('SELECT project_id FROM tasks WHERE id = ?').get(taskId);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('subtask:deleted', {
|
||||
taskId,
|
||||
subtaskId
|
||||
});
|
||||
|
||||
res.json({ message: 'Unteraufgabe gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen des Subtasks:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
899
backend/routes/tasks.js
Normale Datei
899
backend/routes/tasks.js
Normale Datei
@ -0,0 +1,899 @@
|
||||
/**
|
||||
* TASKMATE - Task Routes
|
||||
* ======================
|
||||
* CRUD für Aufgaben
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators } = require('../middleware/validation');
|
||||
const notificationService = require('../services/notificationService');
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: Historie-Eintrag erstellen
|
||||
*/
|
||||
function addHistory(db, taskId, userId, action, fieldChanged = null, oldValue = null, newValue = null) {
|
||||
db.prepare(`
|
||||
INSERT INTO history (task_id, user_id, action, field_changed, old_value, new_value)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(taskId, userId, action, fieldChanged, oldValue, newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hilfsfunktion: Vollständige Task-Daten laden
|
||||
*/
|
||||
function getFullTask(db, taskId) {
|
||||
const task = db.prepare(`
|
||||
SELECT t.*,
|
||||
c.username as creator_name
|
||||
FROM tasks t
|
||||
LEFT JOIN users c ON t.created_by = c.id
|
||||
WHERE t.id = ?
|
||||
`).get(taskId);
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
// Zugewiesene Mitarbeiter laden (Mehrfachzuweisung)
|
||||
const assignees = db.prepare(`
|
||||
SELECT u.id, u.username, u.display_name, u.color
|
||||
FROM task_assignees ta
|
||||
JOIN users u ON ta.user_id = u.id
|
||||
WHERE ta.task_id = ?
|
||||
ORDER BY u.username
|
||||
`).all(taskId);
|
||||
|
||||
// Labels laden
|
||||
const labels = db.prepare(`
|
||||
SELECT l.* FROM labels l
|
||||
JOIN task_labels tl ON l.id = tl.label_id
|
||||
WHERE tl.task_id = ?
|
||||
`).all(taskId);
|
||||
|
||||
// Subtasks laden
|
||||
const subtasks = db.prepare(`
|
||||
SELECT * FROM subtasks WHERE task_id = ? ORDER BY position
|
||||
`).all(taskId);
|
||||
|
||||
// Anhänge zählen
|
||||
const attachmentCount = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM attachments WHERE task_id = ?'
|
||||
).get(taskId).count;
|
||||
|
||||
// Links zählen
|
||||
const linkCount = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM links WHERE task_id = ?'
|
||||
).get(taskId).count;
|
||||
|
||||
// Kommentare zählen
|
||||
const commentCount = db.prepare(
|
||||
'SELECT COUNT(*) as count FROM comments WHERE task_id = ?'
|
||||
).get(taskId).count;
|
||||
|
||||
// Verknüpfte Genehmigungen laden
|
||||
const proposals = db.prepare(`
|
||||
SELECT p.id, p.title, p.approved, p.approved_by,
|
||||
u.display_name as approved_by_name
|
||||
FROM proposals p
|
||||
LEFT JOIN users u ON p.approved_by = u.id
|
||||
WHERE p.task_id = ? AND p.archived = 0
|
||||
ORDER BY p.created_at DESC
|
||||
`).all(taskId);
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
projectId: task.project_id,
|
||||
columnId: task.column_id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
priority: task.priority,
|
||||
startDate: task.start_date,
|
||||
dueDate: task.due_date,
|
||||
// Neues Format: Array von Mitarbeitern
|
||||
assignees: assignees.map(a => ({
|
||||
id: a.id,
|
||||
username: a.username,
|
||||
display_name: a.display_name,
|
||||
color: a.color
|
||||
})),
|
||||
// Rückwärtskompatibilität: assignedTo als erster Mitarbeiter (falls vorhanden)
|
||||
assignedTo: assignees.length > 0 ? assignees[0].id : null,
|
||||
assignedName: assignees.length > 0 ? assignees[0].username : null,
|
||||
assignedColor: assignees.length > 0 ? assignees[0].color : null,
|
||||
timeEstimateMin: task.time_estimate_min,
|
||||
dependsOn: task.depends_on,
|
||||
position: task.position,
|
||||
archived: !!task.archived,
|
||||
createdAt: task.created_at,
|
||||
createdBy: task.created_by,
|
||||
creatorName: task.creator_name,
|
||||
updatedAt: task.updated_at,
|
||||
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color })),
|
||||
subtasks: subtasks.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
completed: !!s.completed,
|
||||
position: s.position
|
||||
})),
|
||||
subtaskProgress: {
|
||||
total: subtasks.length,
|
||||
completed: subtasks.filter(s => s.completed).length
|
||||
},
|
||||
attachmentCount,
|
||||
linkCount,
|
||||
commentCount,
|
||||
proposals: proposals.map(p => ({
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
approved: !!p.approved,
|
||||
approvedByName: p.approved_by_name
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tasks/all
|
||||
* Alle aktiven Aufgaben (nicht archiviert) fuer Auswahl in Vorschlaegen
|
||||
*/
|
||||
router.get('/all', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
const tasks = db.prepare(`
|
||||
SELECT t.id, t.title, t.project_id, p.name as project_name
|
||||
FROM tasks t
|
||||
LEFT JOIN projects p ON t.project_id = p.id
|
||||
WHERE t.archived = 0
|
||||
ORDER BY p.name, t.title
|
||||
`).all();
|
||||
|
||||
res.json(tasks);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen aller Aufgaben:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tasks/project/:projectId
|
||||
* Alle Aufgaben eines Projekts
|
||||
*/
|
||||
router.get('/project/:projectId', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const projectId = req.params.projectId;
|
||||
const includeArchived = req.query.archived === 'true';
|
||||
|
||||
let query = `
|
||||
SELECT t.id FROM tasks t
|
||||
WHERE t.project_id = ?
|
||||
`;
|
||||
|
||||
if (!includeArchived) {
|
||||
query += ' AND t.archived = 0';
|
||||
}
|
||||
|
||||
query += ' ORDER BY t.column_id, t.position';
|
||||
|
||||
const taskIds = db.prepare(query).all(projectId);
|
||||
const tasks = taskIds.map(t => getFullTask(db, t.id));
|
||||
|
||||
res.json(tasks);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Aufgaben:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tasks/search
|
||||
* Aufgaben suchen - durchsucht auch Subtasks, Links, Anhänge und Kommentare
|
||||
* WICHTIG: Diese Route MUSS vor /:id definiert werden!
|
||||
*/
|
||||
router.get('/search', (req, res) => {
|
||||
try {
|
||||
const { q, projectId, assignedTo, priority, dueBefore, dueAfter, labels, archived } = req.query;
|
||||
|
||||
const db = getDb();
|
||||
const params = [];
|
||||
|
||||
// Basis-Query mit LEFT JOINs für tiefe Suche
|
||||
let query = `
|
||||
SELECT DISTINCT t.id FROM tasks t
|
||||
LEFT JOIN subtasks s ON t.id = s.task_id
|
||||
LEFT JOIN links l ON t.id = l.task_id
|
||||
LEFT JOIN attachments a ON t.id = a.task_id
|
||||
LEFT JOIN comments c ON t.id = c.task_id
|
||||
WHERE 1=1
|
||||
`;
|
||||
|
||||
if (projectId) {
|
||||
query += ' AND t.project_id = ?';
|
||||
params.push(projectId);
|
||||
}
|
||||
|
||||
// Erweiterte Textsuche: Titel, Beschreibung, Subtasks, Links, Anhänge, Kommentare
|
||||
if (q) {
|
||||
const searchTerm = `%${q}%`;
|
||||
query += ` AND (
|
||||
t.title LIKE ?
|
||||
OR t.description LIKE ?
|
||||
OR s.title LIKE ?
|
||||
OR l.url LIKE ?
|
||||
OR l.title LIKE ?
|
||||
OR a.original_name LIKE ?
|
||||
OR c.content LIKE ?
|
||||
)`;
|
||||
params.push(searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm, searchTerm);
|
||||
}
|
||||
|
||||
if (assignedTo) {
|
||||
query += ' AND t.assigned_to = ?';
|
||||
params.push(assignedTo);
|
||||
}
|
||||
|
||||
if (priority) {
|
||||
query += ' AND t.priority = ?';
|
||||
params.push(priority);
|
||||
}
|
||||
|
||||
if (dueBefore) {
|
||||
query += ' AND t.due_date <= ?';
|
||||
params.push(dueBefore);
|
||||
}
|
||||
|
||||
if (dueAfter) {
|
||||
query += ' AND t.due_date >= ?';
|
||||
params.push(dueAfter);
|
||||
}
|
||||
|
||||
if (archived !== 'true') {
|
||||
query += ' AND t.archived = 0';
|
||||
}
|
||||
|
||||
if (labels) {
|
||||
const labelIds = labels.split(',').map(id => parseInt(id)).filter(id => !isNaN(id));
|
||||
if (labelIds.length > 0) {
|
||||
query += ` AND t.id IN (
|
||||
SELECT task_id FROM task_labels WHERE label_id IN (${labelIds.map(() => '?').join(',')})
|
||||
)`;
|
||||
params.push(...labelIds);
|
||||
}
|
||||
}
|
||||
|
||||
query += ' ORDER BY t.due_date ASC, t.priority DESC, t.updated_at DESC LIMIT 100';
|
||||
|
||||
const taskIds = db.prepare(query).all(...params);
|
||||
const tasks = taskIds.map(t => getFullTask(db, t.id));
|
||||
|
||||
logger.info(`Suche nach "${q}" in Projekt ${projectId}: ${tasks.length} Treffer`);
|
||||
|
||||
res.json(tasks);
|
||||
} catch (error) {
|
||||
logger.error('Fehler bei der Suche:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/tasks/:id
|
||||
* Einzelne Aufgabe mit allen Details
|
||||
*/
|
||||
router.get('/:id', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const task = getFullTask(db, req.params.id);
|
||||
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
// Historie laden
|
||||
const history = db.prepare(`
|
||||
SELECT h.*, u.display_name, u.color
|
||||
FROM history h
|
||||
JOIN users u ON h.user_id = u.id
|
||||
WHERE h.task_id = ?
|
||||
ORDER BY h.timestamp DESC
|
||||
LIMIT 50
|
||||
`).all(req.params.id);
|
||||
|
||||
task.history = history.map(h => ({
|
||||
id: h.id,
|
||||
action: h.action,
|
||||
fieldChanged: h.field_changed,
|
||||
oldValue: h.old_value,
|
||||
newValue: h.new_value,
|
||||
timestamp: h.timestamp,
|
||||
userName: h.display_name,
|
||||
userColor: h.color
|
||||
}));
|
||||
|
||||
res.json(task);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Aufgabe:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks
|
||||
* Neue Aufgabe erstellen
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
projectId, columnId, title, description, priority,
|
||||
startDate, dueDate, assignees, assignedTo, timeEstimateMin, dependsOn, labels
|
||||
} = req.body;
|
||||
|
||||
// Validierung
|
||||
const errors = [];
|
||||
errors.push(validators.required(projectId, 'Projekt-ID'));
|
||||
errors.push(validators.required(columnId, 'Spalten-ID'));
|
||||
errors.push(validators.required(title, 'Titel'));
|
||||
errors.push(validators.maxLength(title, 200, 'Titel'));
|
||||
if (priority) errors.push(validators.enum(priority, ['low', 'medium', 'high'], 'Priorität'));
|
||||
if (startDate) errors.push(validators.date(startDate, 'Startdatum'));
|
||||
if (dueDate) errors.push(validators.date(dueDate, 'Fälligkeitsdatum'));
|
||||
if (timeEstimateMin) errors.push(validators.positiveInteger(timeEstimateMin, 'Zeitschätzung'));
|
||||
|
||||
const firstError = errors.find(e => e !== null);
|
||||
if (firstError) {
|
||||
return res.status(400).json({ error: firstError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Höchste Position in der Spalte ermitteln
|
||||
const maxPos = db.prepare(
|
||||
'SELECT COALESCE(MAX(position), -1) as max FROM tasks WHERE column_id = ?'
|
||||
).get(columnId).max;
|
||||
|
||||
// Aufgabe erstellen (ohne assigned_to, wird über task_assignees gemacht)
|
||||
const result = db.prepare(`
|
||||
INSERT INTO tasks (
|
||||
project_id, column_id, title, description, priority,
|
||||
start_date, due_date, time_estimate_min, depends_on, position, created_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
projectId, columnId, title, description || null, priority || 'medium',
|
||||
startDate || null, dueDate || null, timeEstimateMin || null,
|
||||
dependsOn || null, maxPos + 1, req.user.id
|
||||
);
|
||||
|
||||
const taskId = result.lastInsertRowid;
|
||||
|
||||
// Mitarbeiter zuweisen (Mehrfachzuweisung)
|
||||
const assigneeIds = assignees && Array.isArray(assignees) ? assignees :
|
||||
(assignedTo ? [assignedTo] : []);
|
||||
if (assigneeIds.length > 0) {
|
||||
const insertAssignee = db.prepare('INSERT INTO task_assignees (task_id, user_id) VALUES (?, ?)');
|
||||
assigneeIds.forEach(userId => {
|
||||
try {
|
||||
insertAssignee.run(taskId, userId);
|
||||
} catch (e) {
|
||||
// User existiert nicht oder bereits zugewiesen
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Labels zuweisen
|
||||
if (labels && Array.isArray(labels)) {
|
||||
const insertLabel = db.prepare('INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)');
|
||||
labels.forEach(labelId => {
|
||||
try {
|
||||
insertLabel.run(taskId, labelId);
|
||||
} catch (e) {
|
||||
// Label existiert nicht, ignorieren
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Historie
|
||||
addHistory(db, taskId, req.user.id, 'created');
|
||||
|
||||
const task = getFullTask(db, taskId);
|
||||
|
||||
logger.info(`Aufgabe erstellt: ${title} (ID: ${taskId}) von ${req.user.username}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${projectId}`).emit('task:created', task);
|
||||
|
||||
// Benachrichtigungen an zugewiesene Mitarbeiter (außer Ersteller)
|
||||
if (assigneeIds.length > 0) {
|
||||
assigneeIds.forEach(assigneeId => {
|
||||
if (assigneeId !== req.user.id) {
|
||||
notificationService.create(assigneeId, 'task:assigned', {
|
||||
taskId: taskId,
|
||||
taskTitle: title,
|
||||
projectId: projectId,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json(task);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen der Aufgabe:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tasks/:id
|
||||
* Aufgabe aktualisieren
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const taskId = req.params.id;
|
||||
const {
|
||||
title, description, priority, columnId, startDate, dueDate, assignees, assignedTo,
|
||||
timeEstimateMin, dependsOn, labels
|
||||
} = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
// Validierung
|
||||
if (title) {
|
||||
const titleError = validators.maxLength(title, 200, 'Titel');
|
||||
if (titleError) return res.status(400).json({ error: titleError });
|
||||
}
|
||||
if (priority) {
|
||||
const prioError = validators.enum(priority, ['low', 'medium', 'high'], 'Priorität');
|
||||
if (prioError) return res.status(400).json({ error: prioError });
|
||||
}
|
||||
if (startDate) {
|
||||
const startDateError = validators.date(startDate, 'Startdatum');
|
||||
if (startDateError) return res.status(400).json({ error: startDateError });
|
||||
}
|
||||
if (dueDate) {
|
||||
const dateError = validators.date(dueDate, 'Fälligkeitsdatum');
|
||||
if (dateError) return res.status(400).json({ error: dateError });
|
||||
}
|
||||
|
||||
// Änderungen tracken für Historie
|
||||
const changes = [];
|
||||
if (title !== undefined && title !== existing.title) {
|
||||
changes.push({ field: 'title', old: existing.title, new: title });
|
||||
}
|
||||
if (description !== undefined && description !== existing.description) {
|
||||
changes.push({ field: 'description', old: existing.description, new: description });
|
||||
}
|
||||
if (priority !== undefined && priority !== existing.priority) {
|
||||
changes.push({ field: 'priority', old: existing.priority, new: priority });
|
||||
}
|
||||
if (columnId !== undefined && columnId !== existing.column_id) {
|
||||
const oldColumn = db.prepare('SELECT name FROM columns WHERE id = ?').get(existing.column_id);
|
||||
const newColumn = db.prepare('SELECT name FROM columns WHERE id = ?').get(columnId);
|
||||
changes.push({ field: 'column', old: oldColumn?.name, new: newColumn?.name });
|
||||
}
|
||||
if (startDate !== undefined && startDate !== existing.start_date) {
|
||||
changes.push({ field: 'start_date', old: existing.start_date, new: startDate });
|
||||
}
|
||||
if (dueDate !== undefined && dueDate !== existing.due_date) {
|
||||
changes.push({ field: 'due_date', old: existing.due_date, new: dueDate });
|
||||
}
|
||||
if (timeEstimateMin !== undefined && timeEstimateMin !== existing.time_estimate_min) {
|
||||
changes.push({ field: 'time_estimate', old: String(existing.time_estimate_min), new: String(timeEstimateMin) });
|
||||
}
|
||||
if (dependsOn !== undefined && dependsOn !== existing.depends_on) {
|
||||
changes.push({ field: 'depends_on', old: String(existing.depends_on), new: String(dependsOn) });
|
||||
}
|
||||
|
||||
// Aufgabe aktualisieren (ohne assigned_to)
|
||||
db.prepare(`
|
||||
UPDATE tasks SET
|
||||
title = COALESCE(?, title),
|
||||
description = ?,
|
||||
priority = COALESCE(?, priority),
|
||||
column_id = ?,
|
||||
start_date = ?,
|
||||
due_date = ?,
|
||||
time_estimate_min = ?,
|
||||
depends_on = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
title || null,
|
||||
description !== undefined ? description : existing.description,
|
||||
priority || null,
|
||||
columnId !== undefined ? columnId : existing.column_id,
|
||||
startDate !== undefined ? startDate : existing.start_date,
|
||||
dueDate !== undefined ? dueDate : existing.due_date,
|
||||
timeEstimateMin !== undefined ? timeEstimateMin : existing.time_estimate_min,
|
||||
dependsOn !== undefined ? dependsOn : existing.depends_on,
|
||||
taskId
|
||||
);
|
||||
|
||||
// Mitarbeiter aktualisieren (Mehrfachzuweisung)
|
||||
if (assignees !== undefined && Array.isArray(assignees)) {
|
||||
// Alte Zuweisungen entfernen
|
||||
db.prepare('DELETE FROM task_assignees WHERE task_id = ?').run(taskId);
|
||||
// Neue Zuweisungen hinzufügen
|
||||
const insertAssignee = db.prepare('INSERT INTO task_assignees (task_id, user_id) VALUES (?, ?)');
|
||||
assignees.forEach(userId => {
|
||||
try {
|
||||
insertAssignee.run(taskId, userId);
|
||||
} catch (e) {
|
||||
// User existiert nicht oder bereits zugewiesen
|
||||
}
|
||||
});
|
||||
changes.push({ field: 'assignees', old: 'changed', new: 'changed' });
|
||||
} else if (assignedTo !== undefined) {
|
||||
// Rückwärtskompatibilität: einzelne Zuweisung
|
||||
db.prepare('DELETE FROM task_assignees WHERE task_id = ?').run(taskId);
|
||||
if (assignedTo) {
|
||||
try {
|
||||
db.prepare('INSERT INTO task_assignees (task_id, user_id) VALUES (?, ?)').run(taskId, assignedTo);
|
||||
} catch (e) {
|
||||
// Ignorieren
|
||||
}
|
||||
}
|
||||
changes.push({ field: 'assignees', old: 'changed', new: 'changed' });
|
||||
}
|
||||
|
||||
// Labels aktualisieren
|
||||
if (labels !== undefined && Array.isArray(labels)) {
|
||||
// Alte Labels entfernen
|
||||
db.prepare('DELETE FROM task_labels WHERE task_id = ?').run(taskId);
|
||||
// Neue Labels zuweisen
|
||||
const insertLabel = db.prepare('INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)');
|
||||
labels.forEach(labelId => {
|
||||
try {
|
||||
insertLabel.run(taskId, labelId);
|
||||
} catch (e) {
|
||||
// Label existiert nicht
|
||||
}
|
||||
});
|
||||
changes.push({ field: 'labels', old: 'changed', new: 'changed' });
|
||||
}
|
||||
|
||||
// Historie-Einträge
|
||||
changes.forEach(change => {
|
||||
addHistory(db, taskId, req.user.id, 'updated', change.field, change.old, change.new);
|
||||
});
|
||||
|
||||
const task = getFullTask(db, taskId);
|
||||
|
||||
logger.info(`Aufgabe aktualisiert: ${task.title} (ID: ${taskId})`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${existing.project_id}`).emit('task:updated', task);
|
||||
|
||||
// Benachrichtigungen für Änderungen senden
|
||||
const taskTitle = title || existing.title;
|
||||
|
||||
// Zuweisung geändert - neue Mitarbeiter benachrichtigen
|
||||
if (assignees !== undefined && Array.isArray(assignees)) {
|
||||
// Alte Mitarbeiter ermitteln
|
||||
const oldAssignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
|
||||
const oldAssigneeIds = oldAssignees.map(a => a.user_id);
|
||||
|
||||
// Neue Mitarbeiter (die vorher nicht zugewiesen waren)
|
||||
const newAssigneeIds = assignees.filter(id => !oldAssigneeIds.includes(id));
|
||||
newAssigneeIds.forEach(assigneeId => {
|
||||
if (assigneeId !== req.user.id) {
|
||||
notificationService.create(assigneeId, 'task:assigned', {
|
||||
taskId: parseInt(taskId),
|
||||
taskTitle: taskTitle,
|
||||
projectId: existing.project_id,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
});
|
||||
|
||||
// Entfernte Mitarbeiter
|
||||
const removedAssigneeIds = oldAssigneeIds.filter(id => !assignees.includes(id));
|
||||
removedAssigneeIds.forEach(assigneeId => {
|
||||
if (assigneeId !== req.user.id) {
|
||||
notificationService.create(assigneeId, 'task:unassigned', {
|
||||
taskId: parseInt(taskId),
|
||||
taskTitle: taskTitle,
|
||||
projectId: existing.project_id,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Priorität auf hoch gesetzt
|
||||
if (priority === 'high' && existing.priority !== 'high') {
|
||||
// Alle Assignees benachrichtigen
|
||||
const currentAssignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
|
||||
currentAssignees.forEach(a => {
|
||||
if (a.user_id !== req.user.id) {
|
||||
notificationService.create(a.user_id, 'task:priority_up', {
|
||||
taskId: parseInt(taskId),
|
||||
taskTitle: taskTitle,
|
||||
projectId: existing.project_id,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fälligkeitsdatum geändert
|
||||
if (dueDate !== undefined && dueDate !== existing.due_date) {
|
||||
const currentAssignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
|
||||
currentAssignees.forEach(a => {
|
||||
if (a.user_id !== req.user.id) {
|
||||
notificationService.create(a.user_id, 'task:due_changed', {
|
||||
taskId: parseInt(taskId),
|
||||
taskTitle: taskTitle,
|
||||
projectId: existing.project_id,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren der Aufgabe:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tasks/:id/move
|
||||
* Aufgabe verschieben (Spalte/Position)
|
||||
*/
|
||||
router.put('/:id/move', (req, res) => {
|
||||
try {
|
||||
const taskId = req.params.id;
|
||||
const { columnId, position } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
const oldColumnId = task.column_id;
|
||||
const oldPosition = task.position;
|
||||
const newColumnId = columnId || oldColumnId;
|
||||
const newPosition = position !== undefined ? position : oldPosition;
|
||||
|
||||
// Spaltenname für Historie
|
||||
const oldColumn = db.prepare('SELECT name FROM columns WHERE id = ?').get(oldColumnId);
|
||||
const newColumn = db.prepare('SELECT name FROM columns WHERE id = ?').get(newColumnId);
|
||||
|
||||
if (oldColumnId !== newColumnId) {
|
||||
// In andere Spalte verschoben
|
||||
// Positionen in alter Spalte anpassen
|
||||
db.prepare(`
|
||||
UPDATE tasks SET position = position - 1
|
||||
WHERE column_id = ? AND position > ?
|
||||
`).run(oldColumnId, oldPosition);
|
||||
|
||||
// Positionen in neuer Spalte anpassen
|
||||
db.prepare(`
|
||||
UPDATE tasks SET position = position + 1
|
||||
WHERE column_id = ? AND position >= ?
|
||||
`).run(newColumnId, newPosition);
|
||||
|
||||
// Task verschieben
|
||||
db.prepare(`
|
||||
UPDATE tasks SET column_id = ?, position = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(newColumnId, newPosition, taskId);
|
||||
|
||||
addHistory(db, taskId, req.user.id, 'moved', 'column', oldColumn?.name, newColumn?.name);
|
||||
} else if (oldPosition !== newPosition) {
|
||||
// Innerhalb der Spalte verschoben
|
||||
if (newPosition > oldPosition) {
|
||||
db.prepare(`
|
||||
UPDATE tasks SET position = position - 1
|
||||
WHERE column_id = ? AND position > ? AND position <= ?
|
||||
`).run(newColumnId, oldPosition, newPosition);
|
||||
} else {
|
||||
db.prepare(`
|
||||
UPDATE tasks SET position = position + 1
|
||||
WHERE column_id = ? AND position >= ? AND position < ?
|
||||
`).run(newColumnId, newPosition, oldPosition);
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE tasks SET position = ?, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
`).run(newPosition, taskId);
|
||||
}
|
||||
|
||||
const updatedTask = getFullTask(db, taskId);
|
||||
|
||||
logger.info(`Aufgabe verschoben: ${task.title} -> ${newColumn?.name || 'Position ' + newPosition}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('task:moved', {
|
||||
task: updatedTask,
|
||||
oldColumnId,
|
||||
newColumnId,
|
||||
oldPosition,
|
||||
newPosition
|
||||
});
|
||||
|
||||
// Benachrichtigung wenn in Erledigt-Spalte verschoben
|
||||
if (oldColumnId !== newColumnId) {
|
||||
const newColumnFull = db.prepare('SELECT filter_category FROM columns WHERE id = ?').get(newColumnId);
|
||||
const oldColumnFull = db.prepare('SELECT filter_category FROM columns WHERE id = ?').get(oldColumnId);
|
||||
|
||||
// Prüfen ob in Erledigt-Spalte verschoben (und vorher nicht dort war)
|
||||
if (newColumnFull?.filter_category === 'completed' && oldColumnFull?.filter_category !== 'completed') {
|
||||
// Alle Assignees benachrichtigen
|
||||
const assignees = db.prepare('SELECT user_id FROM task_assignees WHERE task_id = ?').all(taskId);
|
||||
assignees.forEach(a => {
|
||||
if (a.user_id !== req.user.id) {
|
||||
notificationService.create(a.user_id, 'task:completed', {
|
||||
taskId: parseInt(taskId),
|
||||
taskTitle: task.title,
|
||||
projectId: task.project_id,
|
||||
actorId: req.user.id,
|
||||
actorName: req.user.display_name || req.user.username
|
||||
}, io);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(updatedTask);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Verschieben der Aufgabe:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/tasks/:id/duplicate
|
||||
* Aufgabe duplizieren
|
||||
*/
|
||||
router.post('/:id/duplicate', (req, res) => {
|
||||
try {
|
||||
const taskId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
// Höchste Position ermitteln
|
||||
const maxPos = db.prepare(
|
||||
'SELECT COALESCE(MAX(position), -1) as max FROM tasks WHERE column_id = ?'
|
||||
).get(task.column_id).max;
|
||||
|
||||
// Aufgabe duplizieren
|
||||
const result = db.prepare(`
|
||||
INSERT INTO tasks (
|
||||
project_id, column_id, title, description, priority,
|
||||
due_date, assigned_to, time_estimate_min, position, created_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
task.project_id, task.column_id, task.title + ' (Kopie)', task.description,
|
||||
task.priority, task.due_date, task.assigned_to, task.time_estimate_min,
|
||||
maxPos + 1, req.user.id
|
||||
);
|
||||
|
||||
const newTaskId = result.lastInsertRowid;
|
||||
|
||||
// Labels kopieren
|
||||
const taskLabels = db.prepare('SELECT label_id FROM task_labels WHERE task_id = ?').all(taskId);
|
||||
const insertLabel = db.prepare('INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)');
|
||||
taskLabels.forEach(tl => insertLabel.run(newTaskId, tl.label_id));
|
||||
|
||||
// Subtasks kopieren
|
||||
const subtasks = db.prepare('SELECT * FROM subtasks WHERE task_id = ? ORDER BY position').all(taskId);
|
||||
const insertSubtask = db.prepare('INSERT INTO subtasks (task_id, title, position) VALUES (?, ?, ?)');
|
||||
subtasks.forEach((st, idx) => insertSubtask.run(newTaskId, st.title, idx));
|
||||
|
||||
addHistory(db, newTaskId, req.user.id, 'created', null, null, `Kopie von #${taskId}`);
|
||||
|
||||
const newTask = getFullTask(db, newTaskId);
|
||||
|
||||
logger.info(`Aufgabe dupliziert: ${task.title} -> ${newTask.title}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('task:created', newTask);
|
||||
|
||||
res.status(201).json(newTask);
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Duplizieren:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/tasks/:id/archive
|
||||
* Aufgabe archivieren
|
||||
*/
|
||||
router.put('/:id/archive', (req, res) => {
|
||||
try {
|
||||
const taskId = req.params.id;
|
||||
const { archived } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
db.prepare('UPDATE tasks SET archived = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.run(archived ? 1 : 0, taskId);
|
||||
|
||||
addHistory(db, taskId, req.user.id, archived ? 'archived' : 'restored');
|
||||
|
||||
logger.info(`Aufgabe ${archived ? 'archiviert' : 'wiederhergestellt'}: ${task.title}`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('task:archived', { id: taskId, archived: !!archived });
|
||||
|
||||
res.json({ message: archived ? 'Aufgabe archiviert' : 'Aufgabe wiederhergestellt' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Archivieren:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/tasks/:id
|
||||
* Aufgabe löschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const taskId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
if (!task) {
|
||||
return res.status(404).json({ error: 'Aufgabe nicht gefunden' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId);
|
||||
|
||||
// Positionen neu nummerieren
|
||||
const remainingTasks = db.prepare(
|
||||
'SELECT id FROM tasks WHERE column_id = ? ORDER BY position'
|
||||
).all(task.column_id);
|
||||
|
||||
remainingTasks.forEach((t, idx) => {
|
||||
db.prepare('UPDATE tasks SET position = ? WHERE id = ?').run(idx, t.id);
|
||||
});
|
||||
|
||||
logger.info(`Aufgabe gelöscht: ${task.title} (ID: ${taskId})`);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${task.project_id}`).emit('task:deleted', {
|
||||
id: taskId,
|
||||
columnId: task.column_id
|
||||
});
|
||||
|
||||
res.json({ message: 'Aufgabe gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
336
backend/routes/templates.js
Normale Datei
336
backend/routes/templates.js
Normale Datei
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* TASKMATE - Template Routes
|
||||
* ==========================
|
||||
* CRUD für Aufgaben-Vorlagen
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
const { validators } = require('../middleware/validation');
|
||||
|
||||
/**
|
||||
* GET /api/templates/:projectId
|
||||
* Alle Vorlagen eines Projekts
|
||||
*/
|
||||
router.get('/:projectId', (req, res) => {
|
||||
try {
|
||||
const db = getDb();
|
||||
const templates = db.prepare(`
|
||||
SELECT * FROM task_templates WHERE project_id = ? ORDER BY name
|
||||
`).all(req.params.projectId);
|
||||
|
||||
res.json(templates.map(t => ({
|
||||
id: t.id,
|
||||
projectId: t.project_id,
|
||||
name: t.name,
|
||||
titleTemplate: t.title_template,
|
||||
description: t.description,
|
||||
priority: t.priority,
|
||||
labels: t.labels ? JSON.parse(t.labels) : [],
|
||||
subtasks: t.subtasks ? JSON.parse(t.subtasks) : [],
|
||||
timeEstimateMin: t.time_estimate_min
|
||||
})));
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Vorlagen:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/templates
|
||||
* Neue Vorlage erstellen
|
||||
*/
|
||||
router.post('/', (req, res) => {
|
||||
try {
|
||||
const {
|
||||
projectId, name, titleTemplate, description,
|
||||
priority, labels, subtasks, timeEstimateMin
|
||||
} = req.body;
|
||||
|
||||
// Validierung
|
||||
const errors = [];
|
||||
errors.push(validators.required(projectId, 'Projekt-ID'));
|
||||
errors.push(validators.required(name, 'Name'));
|
||||
errors.push(validators.maxLength(name, 50, 'Name'));
|
||||
if (titleTemplate) errors.push(validators.maxLength(titleTemplate, 200, 'Titel-Vorlage'));
|
||||
if (priority) errors.push(validators.enum(priority, ['low', 'medium', 'high'], 'Priorität'));
|
||||
|
||||
const firstError = errors.find(e => e !== null);
|
||||
if (firstError) {
|
||||
return res.status(400).json({ error: firstError });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO task_templates (
|
||||
project_id, name, title_template, description,
|
||||
priority, labels, subtasks, time_estimate_min
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
projectId,
|
||||
name,
|
||||
titleTemplate || null,
|
||||
description || null,
|
||||
priority || 'medium',
|
||||
labels ? JSON.stringify(labels) : null,
|
||||
subtasks ? JSON.stringify(subtasks) : null,
|
||||
timeEstimateMin || null
|
||||
);
|
||||
|
||||
const template = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(result.lastInsertRowid);
|
||||
|
||||
logger.info(`Vorlage erstellt: ${name} in Projekt ${projectId}`);
|
||||
|
||||
res.status(201).json({
|
||||
id: template.id,
|
||||
projectId: template.project_id,
|
||||
name: template.name,
|
||||
titleTemplate: template.title_template,
|
||||
description: template.description,
|
||||
priority: template.priority,
|
||||
labels: template.labels ? JSON.parse(template.labels) : [],
|
||||
subtasks: template.subtasks ? JSON.parse(template.subtasks) : [],
|
||||
timeEstimateMin: template.time_estimate_min
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen der Vorlage:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/templates/:id
|
||||
* Vorlage aktualisieren
|
||||
*/
|
||||
router.put('/:id', (req, res) => {
|
||||
try {
|
||||
const templateId = req.params.id;
|
||||
const {
|
||||
name, titleTemplate, description,
|
||||
priority, labels, subtasks, timeEstimateMin
|
||||
} = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const existing = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(templateId);
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: 'Vorlage nicht gefunden' });
|
||||
}
|
||||
|
||||
// Validierung
|
||||
if (name) {
|
||||
const nameError = validators.maxLength(name, 50, 'Name');
|
||||
if (nameError) return res.status(400).json({ error: nameError });
|
||||
}
|
||||
if (priority) {
|
||||
const prioError = validators.enum(priority, ['low', 'medium', 'high'], 'Priorität');
|
||||
if (prioError) return res.status(400).json({ error: prioError });
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
UPDATE task_templates SET
|
||||
name = COALESCE(?, name),
|
||||
title_template = ?,
|
||||
description = ?,
|
||||
priority = COALESCE(?, priority),
|
||||
labels = ?,
|
||||
subtasks = ?,
|
||||
time_estimate_min = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name || null,
|
||||
titleTemplate !== undefined ? titleTemplate : existing.title_template,
|
||||
description !== undefined ? description : existing.description,
|
||||
priority || null,
|
||||
labels !== undefined ? JSON.stringify(labels) : existing.labels,
|
||||
subtasks !== undefined ? JSON.stringify(subtasks) : existing.subtasks,
|
||||
timeEstimateMin !== undefined ? timeEstimateMin : existing.time_estimate_min,
|
||||
templateId
|
||||
);
|
||||
|
||||
const template = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(templateId);
|
||||
|
||||
logger.info(`Vorlage aktualisiert: ${template.name} (ID: ${templateId})`);
|
||||
|
||||
res.json({
|
||||
id: template.id,
|
||||
projectId: template.project_id,
|
||||
name: template.name,
|
||||
titleTemplate: template.title_template,
|
||||
description: template.description,
|
||||
priority: template.priority,
|
||||
labels: template.labels ? JSON.parse(template.labels) : [],
|
||||
subtasks: template.subtasks ? JSON.parse(template.subtasks) : [],
|
||||
timeEstimateMin: template.time_estimate_min
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aktualisieren der Vorlage:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/templates/:id/create-task
|
||||
* Aufgabe aus Vorlage erstellen
|
||||
*/
|
||||
router.post('/:id/create-task', (req, res) => {
|
||||
try {
|
||||
const templateId = req.params.id;
|
||||
const { columnId, title, assignedTo, dueDate } = req.body;
|
||||
|
||||
const db = getDb();
|
||||
|
||||
const template = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(templateId);
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Vorlage nicht gefunden' });
|
||||
}
|
||||
|
||||
// columnId ist erforderlich
|
||||
if (!columnId) {
|
||||
return res.status(400).json({ error: 'Spalten-ID erforderlich' });
|
||||
}
|
||||
|
||||
// Höchste Position ermitteln
|
||||
const maxPos = db.prepare(
|
||||
'SELECT COALESCE(MAX(position), -1) as max FROM tasks WHERE column_id = ?'
|
||||
).get(columnId).max;
|
||||
|
||||
// Aufgabe erstellen
|
||||
const taskTitle = title || template.title_template || 'Neue Aufgabe';
|
||||
const result = db.prepare(`
|
||||
INSERT INTO tasks (
|
||||
project_id, column_id, title, description, priority,
|
||||
due_date, assigned_to, time_estimate_min, position, created_by
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
template.project_id,
|
||||
columnId,
|
||||
taskTitle,
|
||||
template.description,
|
||||
template.priority || 'medium',
|
||||
dueDate || null,
|
||||
assignedTo || null,
|
||||
template.time_estimate_min,
|
||||
maxPos + 1,
|
||||
req.user.id
|
||||
);
|
||||
|
||||
const taskId = result.lastInsertRowid;
|
||||
|
||||
// Labels zuweisen
|
||||
if (template.labels) {
|
||||
const labelIds = JSON.parse(template.labels);
|
||||
const insertLabel = db.prepare('INSERT INTO task_labels (task_id, label_id) VALUES (?, ?)');
|
||||
labelIds.forEach(labelId => {
|
||||
try {
|
||||
insertLabel.run(taskId, labelId);
|
||||
} catch (e) { /* Label existiert nicht mehr */ }
|
||||
});
|
||||
}
|
||||
|
||||
// Subtasks erstellen
|
||||
if (template.subtasks) {
|
||||
const subtaskTitles = JSON.parse(template.subtasks);
|
||||
const insertSubtask = db.prepare(
|
||||
'INSERT INTO subtasks (task_id, title, position) VALUES (?, ?, ?)'
|
||||
);
|
||||
subtaskTitles.forEach((st, idx) => {
|
||||
insertSubtask.run(taskId, st, idx);
|
||||
});
|
||||
}
|
||||
|
||||
// Historie
|
||||
db.prepare(`
|
||||
INSERT INTO history (task_id, user_id, action, new_value)
|
||||
VALUES (?, ?, 'created', ?)
|
||||
`).run(taskId, req.user.id, `Aus Vorlage: ${template.name}`);
|
||||
|
||||
logger.info(`Aufgabe aus Vorlage erstellt: ${taskTitle} (Vorlage: ${template.name})`);
|
||||
|
||||
// Vollständige Task-Daten laden (vereinfacht)
|
||||
const task = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId);
|
||||
const labels = db.prepare(`
|
||||
SELECT l.* FROM labels l
|
||||
JOIN task_labels tl ON l.id = tl.label_id
|
||||
WHERE tl.task_id = ?
|
||||
`).all(taskId);
|
||||
const subtasks = db.prepare('SELECT * FROM subtasks WHERE task_id = ? ORDER BY position').all(taskId);
|
||||
|
||||
// WebSocket
|
||||
const io = req.app.get('io');
|
||||
io.to(`project:${template.project_id}`).emit('task:created', {
|
||||
id: task.id,
|
||||
projectId: task.project_id,
|
||||
columnId: task.column_id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
priority: task.priority,
|
||||
dueDate: task.due_date,
|
||||
assignedTo: task.assigned_to,
|
||||
timeEstimateMin: task.time_estimate_min,
|
||||
position: task.position,
|
||||
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color })),
|
||||
subtasks: subtasks.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
completed: !!s.completed,
|
||||
position: s.position
|
||||
}))
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
id: task.id,
|
||||
projectId: task.project_id,
|
||||
columnId: task.column_id,
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
priority: task.priority,
|
||||
dueDate: task.due_date,
|
||||
assignedTo: task.assigned_to,
|
||||
timeEstimateMin: task.time_estimate_min,
|
||||
position: task.position,
|
||||
labels: labels.map(l => ({ id: l.id, name: l.name, color: l.color })),
|
||||
subtasks: subtasks.map(s => ({
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
completed: !!s.completed,
|
||||
position: s.position
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen aus Vorlage:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/templates/:id
|
||||
* Vorlage löschen
|
||||
*/
|
||||
router.delete('/:id', (req, res) => {
|
||||
try {
|
||||
const templateId = req.params.id;
|
||||
const db = getDb();
|
||||
|
||||
const template = db.prepare('SELECT * FROM task_templates WHERE id = ?').get(templateId);
|
||||
if (!template) {
|
||||
return res.status(404).json({ error: 'Vorlage nicht gefunden' });
|
||||
}
|
||||
|
||||
db.prepare('DELETE FROM task_templates WHERE id = ?').run(templateId);
|
||||
|
||||
logger.info(`Vorlage gelöscht: ${template.name} (ID: ${templateId})`);
|
||||
|
||||
res.json({ message: 'Vorlage gelöscht' });
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Löschen der Vorlage:', { error: error.message });
|
||||
res.status(500).json({ error: 'Interner Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
307
backend/server.js
Normale Datei
307
backend/server.js
Normale Datei
@ -0,0 +1,307 @@
|
||||
/**
|
||||
* TASKMATE - Hauptserver
|
||||
* ======================
|
||||
* Node.js/Express Backend mit Socket.io für Echtzeit-Sync
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
const path = require('path');
|
||||
const helmet = require('helmet');
|
||||
const cors = require('cors');
|
||||
const cookieParser = require('cookie-parser');
|
||||
|
||||
// Lokale Module
|
||||
const database = require('./database');
|
||||
const logger = require('./utils/logger');
|
||||
const backup = require('./utils/backup');
|
||||
const { authenticateToken, authenticateSocket } = require('./middleware/auth');
|
||||
const csrfProtection = require('./middleware/csrf');
|
||||
|
||||
// Routes
|
||||
const authRoutes = require('./routes/auth');
|
||||
const projectRoutes = require('./routes/projects');
|
||||
const columnRoutes = require('./routes/columns');
|
||||
const taskRoutes = require('./routes/tasks');
|
||||
const subtaskRoutes = require('./routes/subtasks');
|
||||
const commentRoutes = require('./routes/comments');
|
||||
const labelRoutes = require('./routes/labels');
|
||||
const fileRoutes = require('./routes/files');
|
||||
const linkRoutes = require('./routes/links');
|
||||
const templateRoutes = require('./routes/templates');
|
||||
const statsRoutes = require('./routes/stats');
|
||||
const exportRoutes = require('./routes/export');
|
||||
const importRoutes = require('./routes/import');
|
||||
const healthRoutes = require('./routes/health');
|
||||
const adminRoutes = require('./routes/admin');
|
||||
const proposalRoutes = require('./routes/proposals');
|
||||
const notificationRoutes = require('./routes/notifications');
|
||||
const notificationService = require('./services/notificationService');
|
||||
const gitRoutes = require('./routes/git');
|
||||
const applicationsRoutes = require('./routes/applications');
|
||||
const giteaRoutes = require('./routes/gitea');
|
||||
|
||||
// Express App erstellen
|
||||
const app = express();
|
||||
const server = http.createServer(app);
|
||||
|
||||
// Socket.io Setup
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: true,
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// MIDDLEWARE
|
||||
// =============================================================================
|
||||
|
||||
// Sicherheits-Header
|
||||
app.use(helmet({
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
scriptSrc: ["'self'"],
|
||||
connectSrc: ["'self'", "ws:", "wss:"]
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
// CORS
|
||||
app.use(cors({
|
||||
origin: true,
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// Body Parser
|
||||
app.use(express.json({ limit: '1mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||
|
||||
// Cookie Parser
|
||||
app.use(cookieParser());
|
||||
|
||||
// Request Logging
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
// Use originalUrl to see the full path including /api prefix
|
||||
logger.info(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Statische Dateien (Frontend)
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
|
||||
// Uploads-Ordner
|
||||
app.use('/uploads', authenticateToken, express.static(process.env.UPLOAD_DIR || path.join(__dirname, 'uploads')));
|
||||
|
||||
// =============================================================================
|
||||
// API ROUTES
|
||||
// =============================================================================
|
||||
|
||||
// Health Check (ohne Auth)
|
||||
app.use('/api/health', healthRoutes);
|
||||
|
||||
// Auth Routes (Login/Logout - teilweise ohne Auth)
|
||||
app.use('/api/auth', authRoutes);
|
||||
|
||||
// Geschützte Routes
|
||||
app.use('/api/projects', authenticateToken, csrfProtection, projectRoutes);
|
||||
app.use('/api/columns', authenticateToken, csrfProtection, columnRoutes);
|
||||
app.use('/api/tasks', authenticateToken, csrfProtection, taskRoutes);
|
||||
app.use('/api/subtasks', authenticateToken, csrfProtection, subtaskRoutes);
|
||||
app.use('/api/comments', authenticateToken, csrfProtection, commentRoutes);
|
||||
app.use('/api/labels', authenticateToken, csrfProtection, labelRoutes);
|
||||
app.use('/api/files', authenticateToken, fileRoutes);
|
||||
app.use('/api/links', authenticateToken, csrfProtection, linkRoutes);
|
||||
app.use('/api/templates', authenticateToken, csrfProtection, templateRoutes);
|
||||
app.use('/api/stats', authenticateToken, statsRoutes);
|
||||
app.use('/api/export', authenticateToken, exportRoutes);
|
||||
app.use('/api/import', authenticateToken, csrfProtection, importRoutes);
|
||||
|
||||
// Admin-Routes (eigene Auth-Middleware)
|
||||
app.use('/api/admin', csrfProtection, adminRoutes);
|
||||
|
||||
// Proposals-Routes (eigene Auth-Middleware)
|
||||
app.use('/api/proposals', csrfProtection, proposalRoutes);
|
||||
|
||||
// Notifications-Routes
|
||||
app.use('/api/notifications', authenticateToken, csrfProtection, notificationRoutes);
|
||||
|
||||
// Git-Routes (lokale Git-Operationen)
|
||||
app.use('/api/git', authenticateToken, csrfProtection, gitRoutes);
|
||||
|
||||
// Applications-Routes (Projekt-Repository-Verknüpfung)
|
||||
app.use('/api/applications', authenticateToken, csrfProtection, applicationsRoutes);
|
||||
|
||||
// Gitea-Routes (Gitea API Integration)
|
||||
app.use('/api/gitea', authenticateToken, csrfProtection, giteaRoutes);
|
||||
|
||||
// =============================================================================
|
||||
// SOCKET.IO
|
||||
// =============================================================================
|
||||
|
||||
// Socket.io Middleware für Authentifizierung
|
||||
io.use(authenticateSocket);
|
||||
|
||||
// Verbundene Clients speichern
|
||||
const connectedClients = new Map();
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const userId = socket.user.id;
|
||||
const username = socket.user.username;
|
||||
|
||||
logger.info(`Socket connected: ${username} (${socket.id})`);
|
||||
|
||||
// Client registrieren
|
||||
connectedClients.set(socket.id, {
|
||||
userId,
|
||||
username,
|
||||
connectedAt: new Date()
|
||||
});
|
||||
|
||||
// User-spezifischen Raum beitreten (für Benachrichtigungen)
|
||||
socket.join(`user:${userId}`);
|
||||
|
||||
// Allen mitteilen, dass jemand online ist
|
||||
io.emit('user:online', {
|
||||
userId,
|
||||
username,
|
||||
onlineUsers: Array.from(connectedClients.values()).map(c => ({
|
||||
userId: c.userId,
|
||||
username: c.username
|
||||
}))
|
||||
});
|
||||
|
||||
// Projekt-Raum beitreten
|
||||
socket.on('project:join', (projectId) => {
|
||||
socket.join(`project:${projectId}`);
|
||||
logger.info(`${username} joined project:${projectId}`);
|
||||
});
|
||||
|
||||
// Projekt-Raum verlassen
|
||||
socket.on('project:leave', (projectId) => {
|
||||
socket.leave(`project:${projectId}`);
|
||||
logger.info(`${username} left project:${projectId}`);
|
||||
});
|
||||
|
||||
// Disconnect
|
||||
socket.on('disconnect', () => {
|
||||
logger.info(`Socket disconnected: ${username} (${socket.id})`);
|
||||
connectedClients.delete(socket.id);
|
||||
|
||||
// Allen mitteilen, dass jemand offline ist
|
||||
io.emit('user:offline', {
|
||||
userId,
|
||||
username,
|
||||
onlineUsers: Array.from(connectedClients.values()).map(c => ({
|
||||
userId: c.userId,
|
||||
username: c.username
|
||||
}))
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Socket.io Instance global verfügbar machen für Routes
|
||||
app.set('io', io);
|
||||
|
||||
// =============================================================================
|
||||
// FEHLERBEHANDLUNG
|
||||
// =============================================================================
|
||||
|
||||
// 404 Handler
|
||||
app.use((req, res, next) => {
|
||||
// API-Anfragen: JSON-Fehler
|
||||
if (req.path.startsWith('/api/')) {
|
||||
return res.status(404).json({ error: 'Endpoint nicht gefunden' });
|
||||
}
|
||||
// Andere Anfragen: index.html (SPA)
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Globaler Error Handler
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error(`Error: ${err.message}`, { stack: err.stack });
|
||||
|
||||
// CSRF-Fehler
|
||||
if (err.code === 'CSRF_ERROR') {
|
||||
return res.status(403).json({ error: 'Ungültiges CSRF-Token' });
|
||||
}
|
||||
|
||||
// Multer-Fehler (Datei-Upload)
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({
|
||||
error: `Datei zu groß. Maximum: ${process.env.MAX_FILE_SIZE_MB || 15} MB`
|
||||
});
|
||||
}
|
||||
|
||||
// Allgemeiner Fehler
|
||||
res.status(err.status || 500).json({
|
||||
error: process.env.NODE_ENV === 'production'
|
||||
? 'Ein Fehler ist aufgetreten'
|
||||
: err.message
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// SERVER STARTEN
|
||||
// =============================================================================
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Datenbank initialisieren
|
||||
database.initialize()
|
||||
.then(() => {
|
||||
// Server starten
|
||||
server.listen(PORT, () => {
|
||||
logger.info(`Server läuft auf Port ${PORT}`);
|
||||
logger.info(`Umgebung: ${process.env.NODE_ENV || 'development'}`);
|
||||
|
||||
// Backup-System starten
|
||||
if (process.env.BACKUP_ENABLED !== 'false') {
|
||||
backup.startScheduler();
|
||||
logger.info('Automatische Backups aktiviert');
|
||||
}
|
||||
|
||||
// Fälligkeits-Benachrichtigungen Scheduler (alle 6 Stunden)
|
||||
setInterval(() => {
|
||||
notificationService.checkDueTasks(io);
|
||||
}, 6 * 60 * 60 * 1000);
|
||||
|
||||
// Erste Prüfung nach 1 Minute
|
||||
setTimeout(() => {
|
||||
notificationService.checkDueTasks(io);
|
||||
logger.info('Fälligkeits-Check für Benachrichtigungen gestartet');
|
||||
}, 60 * 1000);
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error('Fehler beim Starten:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// Graceful Shutdown
|
||||
process.on('SIGTERM', () => {
|
||||
logger.info('SIGTERM empfangen, fahre herunter...');
|
||||
server.close(() => {
|
||||
database.close();
|
||||
logger.info('Server beendet');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
logger.info('SIGINT empfangen, fahre herunter...');
|
||||
server.close(() => {
|
||||
database.close();
|
||||
logger.info('Server beendet');
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
549
backend/services/gitService.js
Normale Datei
549
backend/services/gitService.js
Normale Datei
@ -0,0 +1,549 @@
|
||||
/**
|
||||
* TASKMATE - Git Service
|
||||
* =======================
|
||||
* Lokale Git-Operationen über child_process
|
||||
*/
|
||||
|
||||
const { execSync, exec } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Konvertiert einen Windows-Pfad zu einem Docker-Container-Pfad
|
||||
* z.B. "D:\Projekte\MyApp" -> "/mnt/d/Projekte/MyApp"
|
||||
*/
|
||||
function windowsToContainerPath(windowsPath) {
|
||||
if (!windowsPath) return null;
|
||||
|
||||
// Bereits ein Container-Pfad?
|
||||
if (windowsPath.startsWith('/mnt/')) {
|
||||
return windowsPath;
|
||||
}
|
||||
|
||||
// Windows-Pfad konvertieren (z.B. "C:\foo" oder "C:/foo")
|
||||
const normalized = windowsPath.replace(/\\/g, '/');
|
||||
const match = normalized.match(/^([a-zA-Z]):[\/](.*)$/);
|
||||
|
||||
if (match) {
|
||||
const drive = match[1].toLowerCase();
|
||||
const restPath = match[2];
|
||||
return `/mnt/${drive}/${restPath}`;
|
||||
}
|
||||
|
||||
return windowsPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert einen Docker-Container-Pfad zu einem Windows-Pfad
|
||||
* z.B. "/mnt/d/Projekte/MyApp" -> "D:\Projekte\MyApp"
|
||||
*/
|
||||
function containerToWindowsPath(containerPath) {
|
||||
if (!containerPath) return null;
|
||||
|
||||
const match = containerPath.match(/^\/mnt\/([a-z])\/(.*)$/);
|
||||
if (match) {
|
||||
const drive = match[1].toUpperCase();
|
||||
const restPath = match[2].replace(/\//g, '\\');
|
||||
return `${drive}:\\${restPath}`;
|
||||
}
|
||||
|
||||
return containerPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Pfad existiert und erreichbar ist
|
||||
*/
|
||||
function isPathAccessible(localPath) {
|
||||
const containerPath = windowsToContainerPath(localPath);
|
||||
try {
|
||||
// Prüfe ob das übergeordnete Verzeichnis existiert
|
||||
const parentDir = path.dirname(containerPath);
|
||||
return fs.existsSync(parentDir);
|
||||
} catch (error) {
|
||||
logger.error('Pfadzugriff fehlgeschlagen:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Verzeichnis ein Git-Repository ist
|
||||
*/
|
||||
function isGitRepository(localPath) {
|
||||
const containerPath = windowsToContainerPath(localPath);
|
||||
try {
|
||||
const gitDir = path.join(containerPath, '.git');
|
||||
return fs.existsSync(gitDir);
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Führt einen Git-Befehl aus
|
||||
*/
|
||||
function execGitCommand(command, cwd, options = {}) {
|
||||
const containerPath = windowsToContainerPath(cwd);
|
||||
const timeout = options.timeout || 60000; // 60 Sekunden Standard-Timeout
|
||||
|
||||
try {
|
||||
const result = execSync(command, {
|
||||
cwd: containerPath,
|
||||
encoding: 'utf8',
|
||||
timeout,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10 MB
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0', // Keine interaktiven Prompts
|
||||
GIT_SSH_COMMAND: 'ssh -o StrictHostKeyChecking=no'
|
||||
}
|
||||
});
|
||||
return { success: true, output: result.trim() };
|
||||
} catch (error) {
|
||||
logger.error(`Git-Befehl fehlgeschlagen: ${command}`, error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
stderr: error.stderr?.toString() || ''
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository klonen
|
||||
*/
|
||||
async function cloneRepository(repoUrl, localPath, options = {}) {
|
||||
const containerPath = windowsToContainerPath(localPath);
|
||||
const branch = options.branch || 'main';
|
||||
|
||||
// Prüfe, ob das Zielverzeichnis bereits existiert
|
||||
if (fs.existsSync(containerPath)) {
|
||||
if (isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Verzeichnis enthält bereits ein Git-Repository' };
|
||||
}
|
||||
// Verzeichnis existiert, aber ist kein Git-Repo - prüfe ob leer
|
||||
const files = fs.readdirSync(containerPath);
|
||||
if (files.length > 0) {
|
||||
return { success: false, error: 'Verzeichnis ist nicht leer' };
|
||||
}
|
||||
} else {
|
||||
// Erstelle Verzeichnis
|
||||
fs.mkdirSync(containerPath, { recursive: true });
|
||||
}
|
||||
|
||||
// Gitea-Token für Authentifizierung hinzufügen
|
||||
let authUrl = repoUrl;
|
||||
const giteaToken = process.env.GITEA_TOKEN;
|
||||
if (giteaToken && repoUrl.includes('gitea')) {
|
||||
// Füge Token zur URL hinzu: https://token@gitea.example.com/...
|
||||
authUrl = repoUrl.replace('https://', `https://${giteaToken}@`);
|
||||
}
|
||||
|
||||
const command = `git clone --branch ${branch} "${authUrl}" "${containerPath}"`;
|
||||
|
||||
try {
|
||||
execSync(command, {
|
||||
encoding: 'utf8',
|
||||
timeout: 300000, // 5 Minuten für Clone
|
||||
maxBuffer: 50 * 1024 * 1024, // 50 MB
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`Repository geklont: ${repoUrl} -> ${localPath}`);
|
||||
return { success: true, message: 'Repository erfolgreich geklont' };
|
||||
} catch (error) {
|
||||
logger.error('Clone fehlgeschlagen:', error.message);
|
||||
// Bereinige bei Fehler
|
||||
try {
|
||||
if (fs.existsSync(containerPath)) {
|
||||
fs.rmSync(containerPath, { recursive: true, force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignorieren
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Änderungen ziehen (Pull)
|
||||
*/
|
||||
function pullChanges(localPath, options = {}) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
const branch = options.branch || '';
|
||||
const command = branch ? `git pull origin ${branch}` : 'git pull';
|
||||
|
||||
return execGitCommand(command, localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Änderungen hochladen (Push)
|
||||
*/
|
||||
function pushChanges(localPath, options = {}) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
const branch = options.branch || '';
|
||||
const command = branch ? `git push origin ${branch}` : 'git push';
|
||||
|
||||
return execGitCommand(command, localPath, { timeout: 120000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Git-Status abrufen
|
||||
*/
|
||||
function getStatus(localPath) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
const result = execGitCommand('git status --porcelain', localPath);
|
||||
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const lines = result.output.split('\n').filter(l => l.trim());
|
||||
const changes = lines.map(line => {
|
||||
const status = line.substring(0, 2);
|
||||
const file = line.substring(3);
|
||||
return { status, file };
|
||||
});
|
||||
|
||||
// Zusätzliche Infos
|
||||
const branchResult = execGitCommand('git branch --show-current', localPath);
|
||||
const aheadBehindResult = execGitCommand('git rev-list --left-right --count HEAD...@{upstream} 2>/dev/null || echo "0 0"', localPath);
|
||||
|
||||
let ahead = 0;
|
||||
let behind = 0;
|
||||
if (aheadBehindResult.success) {
|
||||
const parts = aheadBehindResult.output.split(/\s+/);
|
||||
ahead = parseInt(parts[0]) || 0;
|
||||
behind = parseInt(parts[1]) || 0;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branch: branchResult.success ? branchResult.output : 'unknown',
|
||||
changes,
|
||||
hasChanges: changes.length > 0,
|
||||
ahead,
|
||||
behind,
|
||||
isClean: changes.length === 0 && ahead === 0
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Änderungen stagen
|
||||
*/
|
||||
function stageAll(localPath) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
return execGitCommand('git add -A', localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit erstellen
|
||||
*/
|
||||
function commit(localPath, message) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
if (!message || message.trim() === '') {
|
||||
return { success: false, error: 'Commit-Nachricht erforderlich' };
|
||||
}
|
||||
|
||||
// Escape für Shell
|
||||
const escapedMessage = message.replace(/"/g, '\\"');
|
||||
return execGitCommand(`git commit -m "${escapedMessage}"`, localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit-Historie abrufen
|
||||
*/
|
||||
function getCommitHistory(localPath, limit = 20) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
const format = '%H|%h|%an|%ae|%at|%s';
|
||||
const result = execGitCommand(`git log -${limit} --format="${format}"`, localPath);
|
||||
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const commits = result.output.split('\n').filter(l => l.trim()).map(line => {
|
||||
const [hash, shortHash, author, email, timestamp, subject] = line.split('|');
|
||||
return {
|
||||
hash,
|
||||
shortHash,
|
||||
author,
|
||||
email,
|
||||
date: new Date(parseInt(timestamp) * 1000).toISOString(),
|
||||
message: subject
|
||||
};
|
||||
});
|
||||
|
||||
return { success: true, commits };
|
||||
}
|
||||
|
||||
/**
|
||||
* Branches auflisten
|
||||
*/
|
||||
function getBranches(localPath) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
const result = execGitCommand('git branch -a', localPath);
|
||||
|
||||
if (!result.success) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const branches = result.output.split('\n').filter(l => l.trim()).map(line => {
|
||||
const isCurrent = line.startsWith('*');
|
||||
const name = line.replace(/^\*?\s+/, '').trim();
|
||||
const isRemote = name.startsWith('remotes/');
|
||||
return { name, isCurrent, isRemote };
|
||||
});
|
||||
|
||||
return { success: true, branches };
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch wechseln
|
||||
*/
|
||||
function checkoutBranch(localPath, branch) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
return execGitCommand(`git checkout ${branch}`, localPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch von Remote
|
||||
*/
|
||||
function fetchRemote(localPath) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
return execGitCommand('git fetch --all', localPath, { timeout: 120000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote-URL abrufen
|
||||
*/
|
||||
function getRemoteUrl(localPath) {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
const result = execGitCommand('git remote get-url origin', localPath);
|
||||
|
||||
if (result.success) {
|
||||
// Entferne Token aus URL für Anzeige
|
||||
let url = result.output;
|
||||
url = url.replace(/https:\/\/[^@]+@/, 'https://');
|
||||
return { success: true, url };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git Repository initialisieren
|
||||
*/
|
||||
function initRepository(localPath, options = {}) {
|
||||
const containerPath = windowsToContainerPath(localPath);
|
||||
const branch = options.branch || 'main';
|
||||
|
||||
// Prüfe ob bereits ein Git-Repo existiert
|
||||
if (isGitRepository(localPath)) {
|
||||
return { success: true, message: 'Git-Repository existiert bereits' };
|
||||
}
|
||||
|
||||
// Prüfe ob Verzeichnis existiert
|
||||
if (!fs.existsSync(containerPath)) {
|
||||
return { success: false, error: 'Verzeichnis existiert nicht' };
|
||||
}
|
||||
|
||||
// Git init ausführen
|
||||
const initResult = execGitCommand(`git init -b ${branch}`, localPath);
|
||||
if (!initResult.success) {
|
||||
return initResult;
|
||||
}
|
||||
|
||||
logger.info(`Git-Repository initialisiert: ${localPath}`);
|
||||
return { success: true, message: 'Git-Repository initialisiert' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote hinzufügen oder aktualisieren
|
||||
*/
|
||||
function setRemote(localPath, remoteUrl, remoteName = 'origin') {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
// Gitea-Token für Authentifizierung hinzufügen
|
||||
let authUrl = remoteUrl;
|
||||
const giteaToken = process.env.GITEA_TOKEN;
|
||||
if (giteaToken && (remoteUrl.includes('gitea') || remoteUrl.includes('aegis-sight'))) {
|
||||
authUrl = remoteUrl.replace('https://', `https://${giteaToken}@`);
|
||||
}
|
||||
|
||||
// Prüfe ob Remote bereits existiert
|
||||
const checkResult = execGitCommand(`git remote get-url ${remoteName}`, localPath);
|
||||
|
||||
if (checkResult.success) {
|
||||
// Remote existiert - aktualisieren
|
||||
const result = execGitCommand(`git remote set-url ${remoteName} "${authUrl}"`, localPath);
|
||||
if (result.success) {
|
||||
logger.info(`Remote '${remoteName}' aktualisiert: ${remoteUrl}`);
|
||||
return { success: true, message: 'Remote aktualisiert' };
|
||||
}
|
||||
return result;
|
||||
} else {
|
||||
// Remote existiert nicht - hinzufügen
|
||||
const result = execGitCommand(`git remote add ${remoteName} "${authUrl}"`, localPath);
|
||||
if (result.success) {
|
||||
logger.info(`Remote '${remoteName}' hinzugefügt: ${remoteUrl}`);
|
||||
return { success: true, message: 'Remote hinzugefügt' };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Remote existiert
|
||||
*/
|
||||
function hasRemote(localPath, remoteName = 'origin') {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = execGitCommand(`git remote get-url ${remoteName}`, localPath);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialen Push mit Upstream-Tracking
|
||||
*/
|
||||
function pushWithUpstream(localPath, branch = null, remoteName = 'origin') {
|
||||
if (!isGitRepository(localPath)) {
|
||||
return { success: false, error: 'Kein Git-Repository' };
|
||||
}
|
||||
|
||||
// Aktuellen Branch ermitteln falls nicht angegeben
|
||||
if (!branch) {
|
||||
const branchResult = execGitCommand('git branch --show-current', localPath);
|
||||
branch = branchResult.success && branchResult.output ? branchResult.output : 'main';
|
||||
}
|
||||
|
||||
// Prüfe ob Commits existieren
|
||||
const logResult = execGitCommand('git rev-parse HEAD', localPath);
|
||||
if (!logResult.success) {
|
||||
// Keine Commits - erstelle einen initialen Commit
|
||||
const statusResult = execGitCommand('git status --porcelain', localPath);
|
||||
if (statusResult.success && statusResult.output.trim()) {
|
||||
// Es gibt Dateien - stage und commit
|
||||
const stageResult = stageAll(localPath);
|
||||
if (!stageResult.success) {
|
||||
return { success: false, error: 'Staging fehlgeschlagen: ' + stageResult.error };
|
||||
}
|
||||
|
||||
const commitResult = commit(localPath, 'Initial commit');
|
||||
if (!commitResult.success) {
|
||||
return { success: false, error: 'Initial commit fehlgeschlagen: ' + commitResult.error };
|
||||
}
|
||||
|
||||
logger.info('Initialer Commit erstellt vor Push');
|
||||
} else {
|
||||
return { success: false, error: 'Keine Commits und keine Dateien zum Committen vorhanden' };
|
||||
}
|
||||
}
|
||||
|
||||
// Push mit -u für Upstream-Tracking
|
||||
return execGitCommand(`git push -u ${remoteName} ${branch}`, localPath, { timeout: 120000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository für Gitea vorbereiten (init, remote, initial commit)
|
||||
*/
|
||||
function prepareForGitea(localPath, remoteUrl, options = {}) {
|
||||
const branch = options.branch || 'main';
|
||||
const containerPath = windowsToContainerPath(localPath);
|
||||
|
||||
// 1. Prüfe ob Git-Repo existiert, wenn nicht initialisieren
|
||||
if (!isGitRepository(localPath)) {
|
||||
const initResult = initRepository(localPath, { branch });
|
||||
if (!initResult.success) {
|
||||
return initResult;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Remote hinzufügen/aktualisieren
|
||||
const remoteResult = setRemote(localPath, remoteUrl);
|
||||
if (!remoteResult.success) {
|
||||
return remoteResult;
|
||||
}
|
||||
|
||||
// 3. Prüfe ob Commits existieren
|
||||
const logResult = execGitCommand('git rev-parse HEAD', localPath);
|
||||
if (!logResult.success) {
|
||||
// Keine Commits - erstelle initialen Commit wenn Dateien vorhanden
|
||||
const statusResult = execGitCommand('git status --porcelain', localPath);
|
||||
if (statusResult.success && statusResult.output.trim()) {
|
||||
// Es gibt Dateien - stage und commit
|
||||
const stageResult = stageAll(localPath);
|
||||
if (!stageResult.success) {
|
||||
return stageResult;
|
||||
}
|
||||
|
||||
const commitResult = commit(localPath, 'Initial commit');
|
||||
if (!commitResult.success) {
|
||||
return commitResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Repository für Gitea vorbereitet: ${localPath} -> ${remoteUrl}`);
|
||||
return { success: true, message: 'Repository für Gitea vorbereitet' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
windowsToContainerPath,
|
||||
containerToWindowsPath,
|
||||
isPathAccessible,
|
||||
isGitRepository,
|
||||
cloneRepository,
|
||||
pullChanges,
|
||||
pushChanges,
|
||||
getStatus,
|
||||
stageAll,
|
||||
commit,
|
||||
getCommitHistory,
|
||||
getBranches,
|
||||
checkoutBranch,
|
||||
fetchRemote,
|
||||
getRemoteUrl,
|
||||
initRepository,
|
||||
setRemote,
|
||||
hasRemote,
|
||||
pushWithUpstream,
|
||||
prepareForGitea
|
||||
};
|
||||
300
backend/services/giteaService.js
Normale Datei
300
backend/services/giteaService.js
Normale Datei
@ -0,0 +1,300 @@
|
||||
/**
|
||||
* TASKMATE - Gitea Service
|
||||
* =========================
|
||||
* Integration mit Gitea API
|
||||
*/
|
||||
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const GITEA_URL = process.env.GITEA_URL || 'https://gitea-undso.aegis-sight.de';
|
||||
const GITEA_TOKEN = process.env.GITEA_TOKEN;
|
||||
const GITEA_ORG = process.env.GITEA_ORG || 'AegisSight'; // Standard-Organisation für neue Repos
|
||||
|
||||
/**
|
||||
* Basis-Fetch für Gitea API
|
||||
*/
|
||||
async function giteaFetch(endpoint, options = {}) {
|
||||
if (!GITEA_TOKEN) {
|
||||
throw new Error('Gitea-Token nicht konfiguriert');
|
||||
}
|
||||
|
||||
const url = `${GITEA_URL}/api/v1${endpoint}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `token ${GITEA_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(`Gitea API Fehler: ${response.status} - ${errorText}`);
|
||||
throw new Error(`Gitea API Fehler: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Alle Repositories der Organisation abrufen
|
||||
*/
|
||||
async function listRepositories(options = {}) {
|
||||
try {
|
||||
const page = options.page || 1;
|
||||
const limit = options.limit || 50;
|
||||
|
||||
// Repositories der Organisation abrufen
|
||||
const repos = await giteaFetch(`/orgs/${GITEA_ORG}/repos?page=${page}&limit=${limit}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
repositories: repos.map(repo => ({
|
||||
id: repo.id,
|
||||
name: repo.name,
|
||||
fullName: repo.full_name,
|
||||
owner: repo.owner.login,
|
||||
description: repo.description || '',
|
||||
cloneUrl: repo.clone_url,
|
||||
htmlUrl: repo.html_url,
|
||||
defaultBranch: repo.default_branch,
|
||||
private: repo.private,
|
||||
fork: repo.fork,
|
||||
stars: repo.stars_count,
|
||||
forks: repo.forks_count,
|
||||
updatedAt: repo.updated_at,
|
||||
createdAt: repo.created_at
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen der Repositories:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository-Details abrufen
|
||||
*/
|
||||
async function getRepository(owner, repo) {
|
||||
try {
|
||||
const repoData = await giteaFetch(`/repos/${owner}/${repo}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
repository: {
|
||||
id: repoData.id,
|
||||
name: repoData.name,
|
||||
fullName: repoData.full_name,
|
||||
owner: repoData.owner.login,
|
||||
description: repoData.description || '',
|
||||
cloneUrl: repoData.clone_url,
|
||||
htmlUrl: repoData.html_url,
|
||||
defaultBranch: repoData.default_branch,
|
||||
private: repoData.private,
|
||||
fork: repoData.fork,
|
||||
stars: repoData.stars_count,
|
||||
forks: repoData.forks_count,
|
||||
size: repoData.size,
|
||||
updatedAt: repoData.updated_at,
|
||||
createdAt: repoData.created_at
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Abrufen des Repositories ${owner}/${repo}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Branches eines Repositories abrufen
|
||||
*/
|
||||
async function getRepositoryBranches(owner, repo) {
|
||||
try {
|
||||
const branches = await giteaFetch(`/repos/${owner}/${repo}/branches`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
branches: branches.map(branch => ({
|
||||
name: branch.name,
|
||||
commit: branch.commit?.id,
|
||||
protected: branch.protected
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Abrufen der Branches für ${owner}/${repo}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Commits eines Repositories abrufen
|
||||
*/
|
||||
async function getRepositoryCommits(owner, repo, options = {}) {
|
||||
try {
|
||||
const page = options.page || 1;
|
||||
const limit = options.limit || 20;
|
||||
const branch = options.branch || '';
|
||||
|
||||
let endpoint = `/repos/${owner}/${repo}/commits?page=${page}&limit=${limit}`;
|
||||
if (branch) {
|
||||
endpoint += `&sha=${branch}`;
|
||||
}
|
||||
|
||||
const commits = await giteaFetch(endpoint);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
commits: commits.map(commit => ({
|
||||
sha: commit.sha,
|
||||
shortSha: commit.sha.substring(0, 7),
|
||||
message: commit.commit.message,
|
||||
author: commit.commit.author.name,
|
||||
email: commit.commit.author.email,
|
||||
date: commit.commit.author.date,
|
||||
htmlUrl: commit.html_url
|
||||
}))
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Abrufen der Commits für ${owner}/${repo}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Neues Repository in der Organisation erstellen
|
||||
*/
|
||||
async function createRepository(name, options = {}) {
|
||||
try {
|
||||
// Repository unter der Organisation erstellen
|
||||
const repoData = await giteaFetch(`/orgs/${GITEA_ORG}/repos`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
description: options.description || '',
|
||||
private: options.private !== false,
|
||||
auto_init: options.autoInit !== false,
|
||||
default_branch: options.defaultBranch || 'main',
|
||||
readme: options.readme || 'Default'
|
||||
})
|
||||
});
|
||||
|
||||
logger.info(`Repository in Organisation ${GITEA_ORG} erstellt: ${repoData.full_name}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
repository: {
|
||||
id: repoData.id,
|
||||
name: repoData.name,
|
||||
fullName: repoData.full_name,
|
||||
owner: repoData.owner.login,
|
||||
cloneUrl: repoData.clone_url,
|
||||
htmlUrl: repoData.html_url,
|
||||
defaultBranch: repoData.default_branch
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen des Repositories:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository löschen
|
||||
*/
|
||||
async function deleteRepository(owner, repo) {
|
||||
try {
|
||||
await giteaFetch(`/repos/${owner}/${repo}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
logger.info(`Repository gelöscht: ${owner}/${repo}`);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
logger.error(`Fehler beim Löschen des Repositories ${owner}/${repo}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Authentifizierten Benutzer abrufen
|
||||
*/
|
||||
async function getCurrentUser() {
|
||||
try {
|
||||
const user = await giteaFetch('/user');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
fullName: user.full_name,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatar_url
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Abrufen des aktuellen Benutzers:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob die Gitea-Verbindung funktioniert
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
const result = await getCurrentUser();
|
||||
return {
|
||||
success: result.success,
|
||||
connected: result.success,
|
||||
user: result.user,
|
||||
giteaUrl: GITEA_URL,
|
||||
organization: GITEA_ORG
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
connected: false,
|
||||
error: error.message,
|
||||
giteaUrl: GITEA_URL,
|
||||
organization: GITEA_ORG
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clone-URL mit Token für private Repos
|
||||
*/
|
||||
function getAuthenticatedCloneUrl(cloneUrl) {
|
||||
if (!GITEA_TOKEN) {
|
||||
return cloneUrl;
|
||||
}
|
||||
// Füge Token zur URL hinzu
|
||||
return cloneUrl.replace('https://', `https://${GITEA_TOKEN}@`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gitea-URL ohne Token (für Anzeige)
|
||||
*/
|
||||
function getSafeCloneUrl(cloneUrl) {
|
||||
// Entferne Token aus URL falls vorhanden
|
||||
return cloneUrl.replace(/https:\/\/[^@]+@/, 'https://');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
listRepositories,
|
||||
getRepository,
|
||||
getRepositoryBranches,
|
||||
getRepositoryCommits,
|
||||
createRepository,
|
||||
deleteRepository,
|
||||
getCurrentUser,
|
||||
testConnection,
|
||||
getAuthenticatedCloneUrl,
|
||||
getSafeCloneUrl,
|
||||
GITEA_URL,
|
||||
GITEA_ORG
|
||||
};
|
||||
290
backend/services/notificationService.js
Normale Datei
290
backend/services/notificationService.js
Normale Datei
@ -0,0 +1,290 @@
|
||||
/**
|
||||
* TASKMATE - Notification Service
|
||||
* ================================
|
||||
* Zentrale Logik für das Benachrichtigungssystem
|
||||
*/
|
||||
|
||||
const { getDb } = require('../database');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
/**
|
||||
* Benachrichtigungstypen mit Titeln und Icons
|
||||
*/
|
||||
const NOTIFICATION_TYPES = {
|
||||
'task:assigned': {
|
||||
title: (data) => 'Neue Aufgabe zugewiesen',
|
||||
message: (data) => `Du wurdest der Aufgabe "${data.taskTitle}" zugewiesen`
|
||||
},
|
||||
'task:unassigned': {
|
||||
title: (data) => 'Zuweisung entfernt',
|
||||
message: (data) => `Du wurdest von der Aufgabe "${data.taskTitle}" entfernt`
|
||||
},
|
||||
'task:due_soon': {
|
||||
title: (data) => 'Aufgabe bald fällig',
|
||||
message: (data) => `Die Aufgabe "${data.taskTitle}" ist morgen fällig`
|
||||
},
|
||||
'task:completed': {
|
||||
title: (data) => 'Aufgabe erledigt',
|
||||
message: (data) => `Die Aufgabe "${data.taskTitle}" wurde erledigt`
|
||||
},
|
||||
'task:due_changed': {
|
||||
title: (data) => 'Fälligkeitsdatum geändert',
|
||||
message: (data) => `Das Fälligkeitsdatum von "${data.taskTitle}" wurde geändert`
|
||||
},
|
||||
'task:priority_up': {
|
||||
title: (data) => 'Priorität erhöht',
|
||||
message: (data) => `Die Priorität von "${data.taskTitle}" wurde auf "Hoch" gesetzt`
|
||||
},
|
||||
'comment:created': {
|
||||
title: (data) => 'Neuer Kommentar',
|
||||
message: (data) => `${data.actorName} hat "${data.taskTitle}" kommentiert`
|
||||
},
|
||||
'comment:mention': {
|
||||
title: (data) => 'Du wurdest erwähnt',
|
||||
message: (data) => `${data.actorName} hat dich in "${data.taskTitle}" erwähnt`
|
||||
},
|
||||
'approval:pending': {
|
||||
title: (data) => 'Genehmigung erforderlich',
|
||||
message: (data) => `Neue Genehmigung: "${data.proposalTitle}"`
|
||||
},
|
||||
'approval:granted': {
|
||||
title: (data) => 'Genehmigung erteilt',
|
||||
message: (data) => `"${data.proposalTitle}" wurde genehmigt`
|
||||
},
|
||||
'approval:rejected': {
|
||||
title: (data) => 'Genehmigung abgelehnt',
|
||||
message: (data) => `"${data.proposalTitle}" wurde abgelehnt`
|
||||
}
|
||||
};
|
||||
|
||||
const notificationService = {
|
||||
/**
|
||||
* Benachrichtigung erstellen und per WebSocket senden
|
||||
* @param {number} userId - Empfänger
|
||||
* @param {string} type - Benachrichtigungstyp
|
||||
* @param {object} data - Zusätzliche Daten
|
||||
* @param {object} io - Socket.io Instanz
|
||||
* @param {boolean} persistent - Ob die Benachrichtigung persistent ist
|
||||
*/
|
||||
create(userId, type, data, io, persistent = false) {
|
||||
try {
|
||||
const db = getDb();
|
||||
const typeConfig = NOTIFICATION_TYPES[type];
|
||||
|
||||
if (!typeConfig) {
|
||||
logger.warn(`Unbekannter Benachrichtigungstyp: ${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = typeConfig.title(data);
|
||||
const message = typeConfig.message(data);
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO notifications (user_id, type, title, message, task_id, project_id, proposal_id, actor_id, is_persistent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
userId,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
data.taskId || null,
|
||||
data.projectId || null,
|
||||
data.proposalId || null,
|
||||
data.actorId || null,
|
||||
persistent ? 1 : 0
|
||||
);
|
||||
|
||||
const notification = db.prepare(`
|
||||
SELECT n.*, u.display_name as actor_name, u.color as actor_color
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.actor_id = u.id
|
||||
WHERE n.id = ?
|
||||
`).get(result.lastInsertRowid);
|
||||
|
||||
// WebSocket-Event senden
|
||||
if (io) {
|
||||
io.to(`user:${userId}`).emit('notification:new', {
|
||||
notification: this.formatNotification(notification)
|
||||
});
|
||||
|
||||
// Auch aktualisierte Zählung senden
|
||||
const count = this.getUnreadCount(userId);
|
||||
io.to(`user:${userId}`).emit('notification:count', { count });
|
||||
}
|
||||
|
||||
logger.info(`Benachrichtigung erstellt: ${type} für User ${userId}`);
|
||||
return notification;
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Erstellen der Benachrichtigung:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Alle Benachrichtigungen für einen User abrufen
|
||||
*/
|
||||
getForUser(userId, limit = 50) {
|
||||
const db = getDb();
|
||||
const notifications = db.prepare(`
|
||||
SELECT n.*, u.display_name as actor_name, u.color as actor_color
|
||||
FROM notifications n
|
||||
LEFT JOIN users u ON n.actor_id = u.id
|
||||
WHERE n.user_id = ?
|
||||
ORDER BY n.is_persistent DESC, n.created_at DESC
|
||||
LIMIT ?
|
||||
`).all(userId, limit);
|
||||
|
||||
return notifications.map(n => this.formatNotification(n));
|
||||
},
|
||||
|
||||
/**
|
||||
* Ungelesene Anzahl ermitteln
|
||||
*/
|
||||
getUnreadCount(userId) {
|
||||
const db = getDb();
|
||||
const result = db.prepare(`
|
||||
SELECT COUNT(*) as count
|
||||
FROM notifications
|
||||
WHERE user_id = ? AND is_read = 0
|
||||
`).get(userId);
|
||||
return result.count;
|
||||
},
|
||||
|
||||
/**
|
||||
* Als gelesen markieren
|
||||
*/
|
||||
markAsRead(notificationId, userId) {
|
||||
const db = getDb();
|
||||
const result = db.prepare(`
|
||||
UPDATE notifications
|
||||
SET is_read = 1
|
||||
WHERE id = ? AND user_id = ?
|
||||
`).run(notificationId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Alle als gelesen markieren
|
||||
*/
|
||||
markAllAsRead(userId) {
|
||||
const db = getDb();
|
||||
const result = db.prepare(`
|
||||
UPDATE notifications
|
||||
SET is_read = 1
|
||||
WHERE user_id = ? AND is_read = 0
|
||||
`).run(userId);
|
||||
return result.changes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Benachrichtigung löschen (nur nicht-persistente)
|
||||
*/
|
||||
delete(notificationId, userId) {
|
||||
const db = getDb();
|
||||
const result = db.prepare(`
|
||||
DELETE FROM notifications
|
||||
WHERE id = ? AND user_id = ? AND is_persistent = 0
|
||||
`).run(notificationId, userId);
|
||||
return result.changes > 0;
|
||||
},
|
||||
|
||||
/**
|
||||
* Persistente Benachrichtigungen auflösen (z.B. bei Genehmigung)
|
||||
*/
|
||||
resolvePersistent(proposalId) {
|
||||
const db = getDb();
|
||||
const result = db.prepare(`
|
||||
DELETE FROM notifications
|
||||
WHERE proposal_id = ? AND is_persistent = 1
|
||||
`).run(proposalId);
|
||||
logger.info(`${result.changes} persistente Benachrichtigungen für Proposal ${proposalId} aufgelöst`);
|
||||
return result.changes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fälligkeits-Check für Aufgaben (1 Tag vorher)
|
||||
*/
|
||||
checkDueTasks(io) {
|
||||
try {
|
||||
const db = getDb();
|
||||
|
||||
// Morgen berechnen
|
||||
const tomorrow = new Date();
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const tomorrowStr = tomorrow.toISOString().split('T')[0];
|
||||
|
||||
// Aufgaben die morgen fällig sind
|
||||
const tasks = db.prepare(`
|
||||
SELECT t.id, t.title, t.project_id, ta.user_id as assignee_id
|
||||
FROM tasks t
|
||||
JOIN task_assignees ta ON t.id = ta.task_id
|
||||
LEFT JOIN columns c ON t.column_id = c.id
|
||||
WHERE t.due_date = ?
|
||||
AND t.archived = 0
|
||||
AND c.filter_category != 'completed'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM notifications n
|
||||
WHERE n.task_id = t.id
|
||||
AND n.user_id = ta.user_id
|
||||
AND n.type = 'task:due_soon'
|
||||
AND DATE(n.created_at) = DATE('now')
|
||||
)
|
||||
`).all(tomorrowStr);
|
||||
|
||||
let count = 0;
|
||||
tasks.forEach(task => {
|
||||
this.create(task.assignee_id, 'task:due_soon', {
|
||||
taskId: task.id,
|
||||
taskTitle: task.title,
|
||||
projectId: task.project_id
|
||||
}, io);
|
||||
count++;
|
||||
});
|
||||
|
||||
if (count > 0) {
|
||||
logger.info(`${count} Fälligkeits-Benachrichtigungen erstellt`);
|
||||
}
|
||||
|
||||
return count;
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Fälligkeits-Check:', error);
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Benachrichtigung formatieren für Frontend
|
||||
*/
|
||||
formatNotification(notification) {
|
||||
return {
|
||||
id: notification.id,
|
||||
userId: notification.user_id,
|
||||
type: notification.type,
|
||||
title: notification.title,
|
||||
message: notification.message,
|
||||
taskId: notification.task_id,
|
||||
projectId: notification.project_id,
|
||||
proposalId: notification.proposal_id,
|
||||
actorId: notification.actor_id,
|
||||
actorName: notification.actor_name,
|
||||
actorColor: notification.actor_color,
|
||||
isRead: notification.is_read === 1,
|
||||
isPersistent: notification.is_persistent === 1,
|
||||
createdAt: notification.created_at
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Benachrichtigung an mehrere User senden
|
||||
*/
|
||||
createForMultiple(userIds, type, data, io, persistent = false) {
|
||||
const results = [];
|
||||
userIds.forEach(userId => {
|
||||
const result = this.create(userId, type, data, io, persistent);
|
||||
if (result) results.push(result);
|
||||
});
|
||||
return results;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = notificationService;
|
||||
183
backend/utils/backup.js
Normale Datei
183
backend/utils/backup.js
Normale Datei
@ -0,0 +1,183 @@
|
||||
/**
|
||||
* TASKMATE - Backup System
|
||||
* ========================
|
||||
* Automatische Datenbank-Backups
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const logger = require('./logger');
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, '..', 'data');
|
||||
const BACKUP_DIR = process.env.BACKUP_DIR || path.join(__dirname, '..', 'backups');
|
||||
const DB_FILE = path.join(DATA_DIR, 'taskmate.db');
|
||||
|
||||
// Backup-Verzeichnis erstellen falls nicht vorhanden
|
||||
if (!fs.existsSync(BACKUP_DIR)) {
|
||||
fs.mkdirSync(BACKUP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup erstellen
|
||||
*/
|
||||
function createBackup() {
|
||||
try {
|
||||
if (!fs.existsSync(DB_FILE)) {
|
||||
logger.warn('Keine Datenbank zum Sichern gefunden');
|
||||
return null;
|
||||
}
|
||||
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupName = `backup_${timestamp}.db`;
|
||||
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||
|
||||
// Datenbank kopieren
|
||||
fs.copyFileSync(DB_FILE, backupPath);
|
||||
|
||||
// WAL-Datei auch sichern falls vorhanden
|
||||
const walFile = DB_FILE + '-wal';
|
||||
if (fs.existsSync(walFile)) {
|
||||
fs.copyFileSync(walFile, backupPath + '-wal');
|
||||
}
|
||||
|
||||
logger.info(`Backup erstellt: ${backupName}`);
|
||||
|
||||
// Alte Backups aufräumen (behalte nur die letzten 30)
|
||||
cleanupOldBackups(30);
|
||||
|
||||
return backupPath;
|
||||
} catch (error) {
|
||||
logger.error('Backup-Fehler:', { error: error.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Alte Backups löschen
|
||||
*/
|
||||
function cleanupOldBackups(keepCount = 30) {
|
||||
try {
|
||||
const files = fs.readdirSync(BACKUP_DIR)
|
||||
.filter(f => f.startsWith('backup_') && f.endsWith('.db'))
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
const toDelete = files.slice(keepCount);
|
||||
|
||||
toDelete.forEach(file => {
|
||||
const filePath = path.join(BACKUP_DIR, file);
|
||||
fs.unlinkSync(filePath);
|
||||
|
||||
// WAL-Datei auch löschen falls vorhanden
|
||||
const walPath = filePath + '-wal';
|
||||
if (fs.existsSync(walPath)) {
|
||||
fs.unlinkSync(walPath);
|
||||
}
|
||||
|
||||
logger.info(`Altes Backup gelöscht: ${file}`);
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Aufräumen alter Backups:', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup wiederherstellen
|
||||
*/
|
||||
function restoreBackup(backupName) {
|
||||
try {
|
||||
const backupPath = path.join(BACKUP_DIR, backupName);
|
||||
|
||||
if (!fs.existsSync(backupPath)) {
|
||||
throw new Error(`Backup nicht gefunden: ${backupName}`);
|
||||
}
|
||||
|
||||
// Aktuelles DB sichern bevor überschrieben wird
|
||||
if (fs.existsSync(DB_FILE)) {
|
||||
const safetyBackup = DB_FILE + '.before-restore';
|
||||
fs.copyFileSync(DB_FILE, safetyBackup);
|
||||
}
|
||||
|
||||
// Backup wiederherstellen
|
||||
fs.copyFileSync(backupPath, DB_FILE);
|
||||
|
||||
// WAL-Datei auch wiederherstellen falls vorhanden
|
||||
const walBackup = backupPath + '-wal';
|
||||
if (fs.existsSync(walBackup)) {
|
||||
fs.copyFileSync(walBackup, DB_FILE + '-wal');
|
||||
}
|
||||
|
||||
logger.info(`Backup wiederhergestellt: ${backupName}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Restore-Fehler:', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Liste aller Backups
|
||||
*/
|
||||
function listBackups() {
|
||||
try {
|
||||
const files = fs.readdirSync(BACKUP_DIR)
|
||||
.filter(f => f.startsWith('backup_') && f.endsWith('.db'))
|
||||
.map(f => {
|
||||
const filePath = path.join(BACKUP_DIR, f);
|
||||
const stats = fs.statSync(filePath);
|
||||
return {
|
||||
name: f,
|
||||
size: stats.size,
|
||||
created: stats.birthtime
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.created - a.created);
|
||||
|
||||
return files;
|
||||
} catch (error) {
|
||||
logger.error('Fehler beim Auflisten der Backups:', { error: error.message });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-Scheduler starten
|
||||
*/
|
||||
let schedulerInterval = null;
|
||||
|
||||
function startScheduler() {
|
||||
const intervalHours = parseInt(process.env.BACKUP_INTERVAL_HOURS) || 24;
|
||||
const intervalMs = intervalHours * 60 * 60 * 1000;
|
||||
|
||||
// Erstes Backup nach 1 Minute
|
||||
setTimeout(() => {
|
||||
createBackup();
|
||||
}, 60 * 1000);
|
||||
|
||||
// Regelmäßige Backups
|
||||
schedulerInterval = setInterval(() => {
|
||||
createBackup();
|
||||
}, intervalMs);
|
||||
|
||||
logger.info(`Backup-Scheduler gestartet (alle ${intervalHours} Stunden)`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Backup-Scheduler stoppen
|
||||
*/
|
||||
function stopScheduler() {
|
||||
if (schedulerInterval) {
|
||||
clearInterval(schedulerInterval);
|
||||
schedulerInterval = null;
|
||||
logger.info('Backup-Scheduler gestoppt');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createBackup,
|
||||
restoreBackup,
|
||||
listBackups,
|
||||
startScheduler,
|
||||
stopScheduler,
|
||||
cleanupOldBackups
|
||||
};
|
||||
94
backend/utils/logger.js
Normale Datei
94
backend/utils/logger.js
Normale Datei
@ -0,0 +1,94 @@
|
||||
/**
|
||||
* TASKMATE - Logger
|
||||
* =================
|
||||
* Einfaches Logging mit Datei-Ausgabe
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LOG_DIR = process.env.LOG_DIR || path.join(__dirname, '..', 'logs');
|
||||
const LOG_FILE = path.join(LOG_DIR, 'app.log');
|
||||
|
||||
// Log-Verzeichnis erstellen falls nicht vorhanden
|
||||
if (!fs.existsSync(LOG_DIR)) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-Level
|
||||
*/
|
||||
const LEVELS = {
|
||||
ERROR: 'ERROR',
|
||||
WARN: 'WARN',
|
||||
INFO: 'INFO',
|
||||
DEBUG: 'DEBUG'
|
||||
};
|
||||
|
||||
/**
|
||||
* Timestamp formatieren
|
||||
*/
|
||||
function getTimestamp() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-Nachricht schreiben
|
||||
*/
|
||||
function log(level, message, meta = {}) {
|
||||
const timestamp = getTimestamp();
|
||||
const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
|
||||
const logLine = `[${timestamp}] [${level}] ${message}${metaStr}`;
|
||||
|
||||
// Konsole
|
||||
if (level === LEVELS.ERROR) {
|
||||
console.error(logLine);
|
||||
} else if (level === LEVELS.WARN) {
|
||||
console.warn(logLine);
|
||||
} else {
|
||||
console.log(logLine);
|
||||
}
|
||||
|
||||
// Datei (async, non-blocking)
|
||||
fs.appendFile(LOG_FILE, logLine + '\n', (err) => {
|
||||
if (err) console.error('Log-Datei Fehler:', err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Log-Rotation (alte Logs löschen)
|
||||
*/
|
||||
function rotateLogsIfNeeded() {
|
||||
try {
|
||||
const stats = fs.statSync(LOG_FILE);
|
||||
const maxSize = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
if (stats.size > maxSize) {
|
||||
const archiveName = `app.${Date.now()}.log`;
|
||||
fs.renameSync(LOG_FILE, path.join(LOG_DIR, archiveName));
|
||||
|
||||
// Alte Archive löschen (behalte nur die letzten 5)
|
||||
const files = fs.readdirSync(LOG_DIR)
|
||||
.filter(f => f.startsWith('app.') && f.endsWith('.log') && f !== 'app.log')
|
||||
.sort()
|
||||
.reverse();
|
||||
|
||||
files.slice(5).forEach(f => {
|
||||
fs.unlinkSync(path.join(LOG_DIR, f));
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// Datei existiert noch nicht, ignorieren
|
||||
}
|
||||
}
|
||||
|
||||
// Log-Rotation beim Start und alle 6 Stunden prüfen
|
||||
rotateLogsIfNeeded();
|
||||
setInterval(rotateLogsIfNeeded, 6 * 60 * 60 * 1000);
|
||||
|
||||
module.exports = {
|
||||
error: (message, meta) => log(LEVELS.ERROR, message, meta),
|
||||
warn: (message, meta) => log(LEVELS.WARN, message, meta),
|
||||
info: (message, meta) => log(LEVELS.INFO, message, meta),
|
||||
debug: (message, meta) => log(LEVELS.DEBUG, message, meta)
|
||||
};
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren