import { Request, Response } from 'express'; import issueService from '../services/issue.service'; import { permissionService } from '../services/permission.service'; import logger from '../utils/logger'; const param = (req: Request, key: string): string => req.params[key] as string; class IssueController { async getIssues(req: Request, res: Response): Promise { try { const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); // Parse filter query params const filters: { typ_id?: number[]; prioritaet?: string[]; status?: string[]; erstellt_von?: string; zugewiesen_an?: string; } = {}; if (req.query.typ_id) { filters.typ_id = String(req.query.typ_id).split(',').map(Number).filter((n) => !isNaN(n)); } if (req.query.prioritaet) { filters.prioritaet = String(req.query.prioritaet).split(','); } if (req.query.status) { filters.status = String(req.query.status).split(','); } if (req.query.erstellt_von) { filters.erstellt_von = req.query.erstellt_von as string; } if (req.query.zugewiesen_an) { filters.zugewiesen_an = req.query.zugewiesen_an === 'me' ? userId : (req.query.zugewiesen_an as string); } const issues = await issueService.getIssues({ userId, canViewAll, filters }); res.status(200).json({ success: true, data: issues }); } catch (error) { logger.error('IssueController.getIssues error', { error }); res.status(500).json({ success: false, message: 'Issues konnten nicht geladen werden' }); } } async getIssue(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const issue = await issueService.getIssueById(id); if (!issue) { res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); return; } const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) { res.status(403).json({ success: false, message: 'Kein Zugriff' }); return; } res.status(200).json({ success: true, data: issue }); } catch (error) { logger.error('IssueController.getIssue error', { error }); res.status(500).json({ success: false, message: 'Issue konnte nicht geladen werden' }); } } async createIssue(req: Request, res: Response): Promise { const { titel } = req.body; if (!titel || typeof titel !== 'string' || titel.trim().length === 0) { res.status(400).json({ success: false, message: 'Titel ist erforderlich' }); return; } try { const issue = await issueService.createIssue(req.body, req.user!.id); res.status(201).json({ success: true, data: issue }); } catch (error) { logger.error('IssueController.createIssue error', { error }); res.status(500).json({ success: false, message: 'Issue konnte nicht erstellt werden' }); } } async updateIssue(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canEdit = permissionService.hasPermission(groups, 'issues:edit'); const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status'); const existing = await issueService.getIssueById(id); if (!existing) { res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); return; } const isOwner = existing.erstellt_von === userId; const isAssignee = existing.zugewiesen_an === userId; // Determine what update data is allowed let updateData: Record; if (canEdit) { // Full edit access updateData = { ...req.body }; // Explicit null for unassign is handled by 'zugewiesen_an' in data check in service } else if (canChangeStatus || isAssignee) { // Can change status and priority (+ kommentar is handled separately) updateData = {}; if (req.body.status !== undefined) updateData.status = req.body.status; if (req.body.prioritaet !== undefined) updateData.prioritaet = req.body.prioritaet; } else if (isOwner) { // Owner without change_status: can only close own issue or reopen from terminal status updateData = {}; if (req.body.status !== undefined) { const newStatus = req.body.status; const allStatuses = await issueService.getIssueStatuses(); const targetDef = allStatuses.find((s: any) => s.schluessel === newStatus); const currentDef = allStatuses.find((s: any) => s.schluessel === existing.status); if (targetDef?.ist_abschluss) { // Owner can close with any terminal status updateData.status = newStatus; } else if (targetDef?.ist_initial && currentDef?.ist_abschluss) { // Owner can reopen from terminal → initial (requires kommentar) if (!req.body.kommentar || typeof req.body.kommentar !== 'string' || req.body.kommentar.trim().length === 0) { res.status(400).json({ success: false, message: 'Beim Wiedereröffnen ist ein Kommentar erforderlich', }); return; } updateData.status = newStatus; } else { res.status(403).json({ success: false, message: 'Keine Berechtigung für diese Statusänderung' }); return; } } else { // Owner trying to change non-status fields without edit permission res.status(403).json({ success: false, message: 'Keine Berechtigung' }); return; } } else { res.status(403).json({ success: false, message: 'Keine Berechtigung' }); return; } // Validate: if setting status to 'abgelehnt', check if type allows it if (updateData.status === 'abgelehnt' && existing.typ_id) { if (!existing.typ_erlaubt_abgelehnt) { res.status(400).json({ success: false, message: 'Dieser Issue-Typ erlaubt den Status "Abgelehnt" nicht', }); return; } } const issue = await issueService.updateIssue(id, updateData); if (!issue) { res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); return; } // Log history entries for detected changes const fieldLabels: Record = { status: 'Status geändert', prioritaet: 'Priorität geändert', zugewiesen_an: 'Zuweisung geändert', titel: 'Titel geändert', beschreibung: 'Beschreibung geändert', typ_id: 'Typ geändert', faellig_am: 'Fälligkeitsdatum geändert', }; for (const [field, label] of Object.entries(fieldLabels)) { if (field in updateData && updateData[field] !== existing[field]) { const details: Record = { von: existing[field], zu: updateData[field] }; if (field === 'zugewiesen_an') { details.von_name = existing.zugewiesen_an_name || null; details.zu_name = issue.zugewiesen_an_name || null; } if (field === 'status') { details.von_label = existing.status; details.zu_label = issue.status; } issueService.addHistoryEntry(id, label, details, userId); } } // Handle reopen comment (owner reopen flow: terminal → initial) if (isOwner && !canChangeStatus && updateData.status && req.body.kommentar) { const allStatusesForComment = await issueService.getIssueStatuses(); const targetForComment = allStatusesForComment.find((s: any) => s.schluessel === updateData.status); const currentForComment = allStatusesForComment.find((s: any) => s.schluessel === existing.status); if (targetForComment?.ist_initial && currentForComment?.ist_abschluss) { await issueService.addComment(id, userId, `[Wiedereröffnet] ${req.body.kommentar.trim()}`); } else if (req.body.kommentar && updateData.status) { await issueService.addComment(id, userId, req.body.kommentar.trim()); } } else if (req.body.kommentar && updateData.status) { // If kommentar was provided alongside a status change (non-owner flow) await issueService.addComment(id, userId, req.body.kommentar.trim()); } // Re-fetch to include any new comment info const updated = await issueService.getIssueById(id); res.status(200).json({ success: true, data: updated }); } catch (error) { logger.error('IssueController.updateIssue error', { error }); res.status(500).json({ success: false, message: 'Issue konnte nicht aktualisiert werden' }); } } async deleteIssue(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const issue = await issueService.getIssueById(id); if (!issue) { res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); return; } const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canDelete = permissionService.hasPermission(groups, 'issues:delete'); if (!canDelete && issue.erstellt_von !== userId) { res.status(403).json({ success: false, message: 'Keine Berechtigung' }); return; } await issueService.deleteIssue(id); res.status(200).json({ success: true, message: 'Issue gelöscht' }); } catch (error) { logger.error('IssueController.deleteIssue error', { error }); res.status(500).json({ success: false, message: 'Issue konnte nicht gelöscht werden' }); } } async getComments(req: Request, res: Response): Promise { const issueId = parseInt(param(req, 'id'), 10); if (isNaN(issueId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const issue = await issueService.getIssueById(issueId); if (!issue) { res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); return; } const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const canViewAll = permissionService.hasPermission(groups, 'issues:view_all'); if (!canViewAll && issue.erstellt_von !== userId && issue.zugewiesen_an !== userId) { res.status(403).json({ success: false, message: 'Kein Zugriff' }); return; } const comments = await issueService.getComments(issueId); res.status(200).json({ success: true, data: comments }); } catch (error) { logger.error('IssueController.getComments error', { error }); res.status(500).json({ success: false, message: 'Kommentare konnten nicht geladen werden' }); } } async addComment(req: Request, res: Response): Promise { const issueId = parseInt(param(req, 'id'), 10); if (isNaN(issueId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } const { inhalt } = req.body; if (!inhalt || typeof inhalt !== 'string' || inhalt.trim().length === 0) { res.status(400).json({ success: false, message: 'Kommentar darf nicht leer sein' }); return; } try { const issue = await issueService.getIssueById(issueId); if (!issue) { res.status(404).json({ success: false, message: 'Issue nicht gefunden' }); return; } const userId = req.user!.id; const groups: string[] = (req.user as any).groups || []; const isOwner = issue.erstellt_von === userId; const isAssignee = issue.zugewiesen_an === userId; const canChangeStatus = permissionService.hasPermission(groups, 'issues:change_status'); const canEdit = permissionService.hasPermission(groups, 'issues:edit'); // Authorization: owner, assignee, change_status, or edit can comment if (!isOwner && !isAssignee && !canChangeStatus && !canEdit) { res.status(403).json({ success: false, message: 'Keine Berechtigung zum Kommentieren' }); return; } const comment = await issueService.addComment(issueId, userId, inhalt.trim()); res.status(201).json({ success: true, data: comment }); } catch (error) { logger.error('IssueController.addComment error', { error }); res.status(500).json({ success: false, message: 'Kommentar konnte nicht erstellt werden' }); } } // --- Type management --- async getHistory(req: Request, res: Response): Promise { const issueId = parseInt(param(req, 'id'), 10); if (isNaN(issueId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const history = await issueService.getHistory(issueId); res.status(200).json({ success: true, data: history }); } catch (error) { logger.error('IssueController.getHistory error', { error }); res.status(500).json({ success: false, message: 'Historie konnte nicht geladen werden' }); } } async getTypes(_req: Request, res: Response): Promise { try { const types = await issueService.getTypes(); res.status(200).json({ success: true, data: types }); } catch (error) { logger.error('IssueController.getTypes error', { error }); res.status(500).json({ success: false, message: 'Issue-Typen konnten nicht geladen werden' }); } } async createType(req: Request, res: Response): Promise { const { name } = req.body; if (!name || typeof name !== 'string' || name.trim().length === 0) { res.status(400).json({ success: false, message: 'Name ist erforderlich' }); return; } try { const type = await issueService.createType(req.body); res.status(201).json({ success: true, data: type }); } catch (error) { logger.error('IssueController.createType error', { error }); res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht erstellt werden' }); } } async updateType(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const type = await issueService.updateType(id, req.body); if (!type) { res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' }); return; } res.status(200).json({ success: true, data: type }); } catch (error) { logger.error('IssueController.updateType error', { error }); res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht aktualisiert werden' }); } } async deleteType(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const type = await issueService.deactivateType(id); if (!type) { res.status(404).json({ success: false, message: 'Issue-Typ nicht gefunden' }); return; } res.status(200).json({ success: true, data: type }); } catch (error) { logger.error('IssueController.deleteType error', { error }); res.status(500).json({ success: false, message: 'Issue-Typ konnte nicht deaktiviert werden' }); } } async getMembers(_req: Request, res: Response): Promise { try { const members = await issueService.getAssignableMembers(); res.status(200).json({ success: true, data: members }); } catch (error) { logger.error('IssueController.getMembers error', { error }); res.status(500).json({ success: false, message: 'Mitglieder konnten nicht geladen werden' }); } } async getWidgetSummary(_req: Request, res: Response): Promise { try { const counts = await issueService.getIssueCounts(); res.status(200).json({ success: true, data: counts }); } catch (error) { logger.error('IssueController.getWidgetSummary error', { error }); res.status(500).json({ success: false, message: 'Issue-Counts konnten nicht geladen werden' }); } } async getIssueStatuses(_req: Request, res: Response): Promise { try { const items = await issueService.getIssueStatuses(); res.status(200).json({ success: true, data: items }); } catch (error) { logger.error('IssueController.getIssueStatuses error', { error }); res.status(500).json({ success: false, message: 'Issue-Status konnten nicht geladen werden' }); } } async createIssueStatus(req: Request, res: Response): Promise { const { schluessel, bezeichnung } = req.body; if (!schluessel?.trim() || !bezeichnung?.trim()) { res.status(400).json({ success: false, message: 'Schlüssel und Bezeichnung sind erforderlich' }); return; } try { const item = await issueService.createIssueStatus(req.body); res.status(201).json({ success: true, data: item }); } catch (error) { logger.error('IssueController.createIssueStatus error', { error }); res.status(500).json({ success: false, message: 'Issue-Status konnte nicht erstellt werden' }); } } async updateIssueStatus(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const item = await issueService.updateIssueStatus(id, req.body); if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; } res.status(200).json({ success: true, data: item }); } catch (error) { logger.error('IssueController.updateIssueStatus error', { error }); res.status(500).json({ success: false, message: 'Issue-Status konnte nicht aktualisiert werden' }); } } async deleteIssueStatus(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const item = await issueService.deleteIssueStatus(id); if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; } res.status(200).json({ success: true, data: item }); } catch (error) { logger.error('IssueController.deleteIssueStatus error', { error }); res.status(500).json({ success: false, message: 'Issue-Status konnte nicht deaktiviert werden' }); } } async getIssuePriorities(_req: Request, res: Response): Promise { try { const items = await issueService.getIssuePriorities(); res.status(200).json({ success: true, data: items }); } catch (error) { logger.error('IssueController.getIssuePriorities error', { error }); res.status(500).json({ success: false, message: 'Prioritäten konnten nicht geladen werden' }); } } async createIssuePriority(req: Request, res: Response): Promise { const { schluessel, bezeichnung } = req.body; if (!schluessel?.trim() || !bezeichnung?.trim()) { res.status(400).json({ success: false, message: 'Schlüssel und Bezeichnung sind erforderlich' }); return; } try { const item = await issueService.createIssuePriority(req.body); res.status(201).json({ success: true, data: item }); } catch (error) { logger.error('IssueController.createIssuePriority error', { error }); res.status(500).json({ success: false, message: 'Priorität konnte nicht erstellt werden' }); } } async updateIssuePriority(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const item = await issueService.updateIssuePriority(id, req.body); if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; } res.status(200).json({ success: true, data: item }); } catch (error) { logger.error('IssueController.updateIssuePriority error', { error }); res.status(500).json({ success: false, message: 'Priorität konnte nicht aktualisiert werden' }); } } async deleteIssuePriority(req: Request, res: Response): Promise { const id = parseInt(param(req, 'id'), 10); if (isNaN(id)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const item = await issueService.deleteIssuePriority(id); if (!item) { res.status(404).json({ success: false, message: 'Nicht gefunden' }); return; } res.status(200).json({ success: true, data: item }); } catch (error) { logger.error('IssueController.deleteIssuePriority error', { error }); res.status(500).json({ success: false, message: 'Priorität konnte nicht deaktiviert werden' }); } } // --- File management --- async uploadFile(req: Request, res: Response): Promise { const issueId = parseInt(param(req, 'id'), 10); if (isNaN(issueId)) { res.status(400).json({ success: false, message: 'Ungültige Issue-ID' }); return; } const file = req.file as Express.Multer.File | undefined; if (!file) { res.status(400).json({ success: false, message: 'Keine Datei hochgeladen' }); return; } try { const fileRecord = await issueService.addFile(issueId, { dateiname: file.originalname, dateipfad: file.path, dateityp: file.mimetype, dateigroesse: file.size, }, req.user!.id); res.status(201).json({ success: true, data: fileRecord }); } catch (error) { logger.error('IssueController.uploadFile error', { error }); res.status(500).json({ success: false, message: 'Datei konnte nicht hochgeladen werden' }); } } async getFiles(req: Request, res: Response): Promise { const issueId = parseInt(param(req, 'id'), 10); if (isNaN(issueId)) { res.status(400).json({ success: false, message: 'Ungültige ID' }); return; } try { const files = await issueService.getFiles(issueId); res.status(200).json({ success: true, data: files }); } catch (error) { logger.error('IssueController.getFiles error', { error }); res.status(500).json({ success: false, message: 'Dateien konnten nicht geladen werden' }); } } async deleteFile(req: Request, res: Response): Promise { const fileId = param(req, 'fileId'); if (!fileId) { res.status(400).json({ success: false, message: 'Ungültige Datei-ID' }); return; } try { const result = await issueService.deleteFile(fileId, req.user!.id); if (!result) { res.status(404).json({ success: false, message: 'Datei nicht gefunden' }); return; } res.status(200).json({ success: true, message: 'Datei gelöscht' }); } catch (error) { logger.error('IssueController.deleteFile error', { error }); res.status(500).json({ success: false, message: 'Datei konnte nicht gelöscht werden' }); } } } export default new IssueController();