Initial commit
Dieser Commit ist enthalten in:
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;
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren