/** * Tasks routes module. * * Punkt 8: Uses transactions for task creation with values. * Punkt 6: Uses better-sqlite3 synchronous API. * Punkt 23: Rate limiting on task creation. */ const express = require('express'); const db = require('../db'); const { auditLog } = require('../auditLog'); const { authMiddleware, adminMiddleware } = require('../middleware/auth'); const { taskCreateLimiter } = require('../middleware/rateLimit'); const { validate, validateQuery, createTaskSchema, updateTaskStatusSchema, updateTaskValuesSchema, addTaskFieldSchema, paginationSchema } = require('../middleware/validation'); const router = express.Router(); router.use(authMiddleware); // Create task - Punkt 8: Transaction router.post('/', taskCreateLimiter, validate(createTaskSchema), async (req, res) => { const { template_id, title, values, file_path, user_id } = req.validatedBody; // VULN-02: Mass Assignment prevention const targetUserId = (req.user.role === 'admin' && req.body.user_id) ? parseInt(req.body.user_id) : req.user.id; if (!template_id || !title) { return res.status(400).json({ error: 'template_id und title sind erforderlich.' }); } const insertTask = db.prepare('INSERT INTO tasks (template_id, user_id, title, status, file_path) VALUES (?, ?, ?, \'offen\', ?)'); const insertValue = db.prepare('INSERT INTO task_values (task_id, step_id, value, is_checked, file_path, snap_label, snap_type, snap_page_num, snap_ad_field, snap_ad_prefix, snap_dropdown_options, snap_email_source_fields, snap_hidden) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); const createTask = db.transaction(async () => { const info = await insertTask.run(template_id, targetUserId, title, file_path); const taskId = info.lastInsertRowid; if (values.length > 0) { // Fetch step metadata for snapshot const stepIds = values.map(v => v.step_id).filter(Boolean); const stepMetaMap = {}; if (stepIds.length > 0) { const validStepIds = stepIds.filter(id => Number.isInteger(id)); if (validStepIds.length > 0) { const placeholders = validStepIds.map(() => '?').join(','); const steps = await db.prepare(`SELECT id, label, type, page_num, ad_field, ad_prefix, dropdown_options, email_source_fields, hidden FROM template_steps WHERE id IN (${placeholders})`).all(...validStepIds); steps.forEach(s => { stepMetaMap[s.id] = s; }); } } for (const v of values) { const meta = v.step_id ? stepMetaMap[v.step_id] : null; await insertValue.run( taskId, v.step_id, v.value || '', v.is_checked ? 1 : 0, v.file_path || null, meta ? meta.label : null, meta ? meta.type : null, meta ? meta.page_num : null, meta ? meta.ad_field : null, meta ? meta.ad_prefix : null, meta ? meta.dropdown_options : null, meta ? meta.email_source_fields : null, meta ? (meta.hidden ? 1 : 0) : 0 ); } } return taskId; }); try { const taskId = await createTask(); auditLog(req.user?.id, 'create_task', 'task', taskId, `Task created: ${title}`); res.status(201).json({ id: taskId, template_id, user_id: targetUserId, title, status: 'offen', file_path, values }); } catch (err) { console.error('[ERROR] POST /tasks -', err.message); res.status(500).json({ error: 'Interner Serverfehler.' }); } }); // Update task status router.patch('/:id/status', validate(updateTaskStatusSchema), async (req, res) => { const taskId = parseInt(req.params.id); const { status } = req.validatedBody; // VULN-05: BOLA protection const task = await db.prepare('SELECT user_id FROM tasks WHERE id = ?').get(taskId); if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' }); if (task.user_id !== req.user.id && req.user.role !== 'admin') { return res.status(403).json({ error: 'Keine Berechtigung, diese Aufgabe zu aendern.' }); } const info = await db.prepare('UPDATE tasks SET status = ? WHERE id = ?').run(status, taskId); if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' }); auditLog(req.user?.id, 'update_task', 'task', taskId, `Status changed to: ${status}`); res.json({ id: taskId, status }); }); // Update task values (admin only) router.put('/:id/values', adminMiddleware, validate(updateTaskValuesSchema), async (req, res) => { const taskId = parseInt(req.params.id); const { values } = req.validatedBody; const updateValue = db.prepare('UPDATE task_values SET value = ?, is_checked = ? WHERE id = ? AND task_id = ?'); const updateTransaction = db.transaction(async (vals) => { let updated = 0; for (const v of vals) { const info = await updateValue.run(v.value || '', v.is_checked ? 1 : 0, v.id, taskId); updated += info.changes; } return updated; }); try { const updated = await updateTransaction(values); auditLog(req.user?.id, 'update_task', 'task', taskId, `Updated ${updated} task values`); res.json({ updated, taskId }); } catch (err) { res.status(500).json({ error: 'Interner Serverfehler.' }); } }); // Add custom field to task (admin only) router.post('/:id/add-field', adminMiddleware, validate(addTaskFieldSchema), async (req, res) => { const taskId = parseInt(req.params.id); const { label, type, value, page_num, dropdown_options, ad_field, hidden, email_source_fields } = req.validatedBody; const fieldType = type || 'text_input'; const fieldValue = value || ''; const customDropdownOptions = dropdown_options || ''; const customAdField = ad_field || ''; const customHidden = hidden ? 1 : 0; const customEmailSourceFields = email_source_fields || ''; try { const info = await db.prepare( 'INSERT INTO task_values (task_id, step_id, value, is_checked, custom_label, custom_type, custom_dropdown_options, custom_ad_field, custom_hidden, custom_email_source_fields) VALUES (?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)' ).run(taskId, fieldValue, fieldType === 'checkbox' ? 0 : 0, label.trim(), fieldType, customDropdownOptions, customAdField, customHidden, customEmailSourceFields); auditLog(req.user?.id, 'task.add-field', 'task', taskId, `Added field: ${label.trim()}`); res.status(201).json({ id: info.lastInsertRowid, task_id: taskId, custom_label: label.trim(), custom_type: fieldType, value: fieldValue, page_num: page_num || 1, dropdown_options: customDropdownOptions, ad_field: customAdField, hidden: customHidden, email_source_fields: customEmailSourceFields }); } catch (err) { console.error('Add field error:', err.message); res.status(500).json({ error: 'Interner Serverfehler.' }); } }); // Delete custom field from task (admin only) router.delete('/:id/fields/:fieldId', adminMiddleware, async (req, res) => { const taskId = parseInt(req.params.id); const fieldId = parseInt(req.params.fieldId); const info = await db.prepare('DELETE FROM task_values WHERE id = ? AND task_id = ? AND custom_label IS NOT NULL').run(fieldId, taskId); if (info.changes === 0) return res.status(404).json({ error: 'Feld nicht gefunden oder kein benutzerdefiniertes Feld.' }); auditLog(req.user?.id, 'task.delete-field', 'task', taskId, `Deleted field: ${fieldId}`); res.json({ message: 'Feld gelöscht.' }); }); // Delete task (admin only) router.delete('/:id', adminMiddleware, async (req, res) => { const taskId = parseInt(req.params.id); const info = await db.prepare('DELETE FROM tasks WHERE id = ?').run(taskId); if (info.changes === 0) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' }); auditLog(req.user?.id, 'delete_task', 'task', taskId, null); res.json({ message: 'Aufgabe gelöscht.' }); }); // Single task endpoint router.get('/:id', async (req, res) => { const taskId = parseInt(req.params.id); const task = await db.prepare('SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id WHERE t.id = ?').get(taskId); if (!task) return res.status(404).json({ error: 'Aufgabe nicht gefunden.' }); const values = await db.prepare( `SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id = ? ORDER BY ts.step_order ASC, tv.id ASC` ).all(taskId); auditLog(req.user?.id, 'view_task', 'task', taskId, null); res.json({ ...task, values: values || [] }); }); // List tasks with pagination (Punkt 16: bounded limits) router.get('/', validateQuery(paginationSchema), async (req, res) => { const { page, limit } = req.validatedQuery; const offset = (page - 1) * limit; const status = req.query.status; let whereClause = ''; const params = []; if (status && ['offen', 'erledigt'].includes(status)) { whereClause = ' WHERE t.status = ?'; params.push(status); } const countSql = 'SELECT COUNT(*) as total FROM tasks t' + whereClause; const dataSql = 'SELECT t.*, u.name as user_name, u.email as user_email, tpl.name as template_name, tpl.ad_create FROM tasks t LEFT JOIN users u ON t.user_id = u.id LEFT JOIN templates tpl ON t.template_id = tpl.id' + whereClause + ' ORDER BY t.created_at DESC LIMIT ? OFFSET ?'; const countRow = await db.prepare(countSql).get(...params); const tasks = await db.prepare(dataSql).all(...params, limit, offset); const total = countRow?.total || 0; if (tasks.length === 0) return res.json({ tasks: [], total: 0, page, limit, totalPages: 0 }); const taskIds = tasks.map(t => t.id).filter(id => Number.isInteger(id)); if (taskIds.length === 0) return res.json({ tasks: tasks.map(t => ({ ...t, values: [] })), total, page, limit, totalPages: Math.ceil(total / limit) }); const placeholders = taskIds.map(() => '?').join(','); const values = await db.prepare( `SELECT tv.*, COALESCE(ts.label, tv.snap_label) as step_label, COALESCE(ts.type, tv.snap_type) as step_type, COALESCE(ts.page_num, tv.snap_page_num) as page_num, COALESCE(ts.ad_field, tv.snap_ad_field) as ad_field, COALESCE(ts.ad_prefix, tv.snap_ad_prefix) as ad_prefix, COALESCE(ts.dropdown_options, tv.snap_dropdown_options) as dropdown_options, COALESCE(ts.email_source_fields, tv.snap_email_source_fields) as email_source_fields, COALESCE(ts.hidden, tv.snap_hidden) as hidden, tv.custom_label, tv.custom_type, tv.custom_dropdown_options, tv.custom_ad_field, tv.custom_hidden, tv.custom_email_source_fields FROM task_values tv LEFT JOIN template_steps ts ON tv.step_id = ts.id WHERE tv.task_id IN (${placeholders})` ).all(...taskIds); const result = tasks.map(t => ({ ...t, values: values.filter(v => v.task_id === t.id) })); res.json({ tasks: result, total, page, limit, totalPages: Math.ceil(total / limit) }); }); module.exports = router;