import { Router } from "express"; import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { evaluateAutoTransitions } from "../utils/auto-transitions"; import { tasksMinimalCache } from "../utils/cache"; import { eventBus } from "./shared"; import { notifyTaskAssigned } from "../utils/notifyAssignee"; export function registerTaskRoleRoutes(router: ReturnType): void { // === TASK ROLES === router.get('/api/tasks/:id/roles', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) return res.status(400).json({ error: 'Неверный ID задачи' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); const assignedRoles = await storage.getTaskRoles(taskId, req.organizationId!); if (assignedRoles.length > 0) { const hasAccess = await storage.canUserAccessTask( taskId, req.user!.id, req.organizationId!, req.user!.appRole ); if (!hasAccess) { return res.status(403).json({ error: 'Нет доступа к этой задаче' }); } } res.json({ success: true, roles: assignedRoles }); } catch (err) { console.error('Get task roles error:', err); res.status(500).json({ error: 'Ошибка при получении ролей задачи' }); } } ); router.post('/api/tasks/:id/roles', authenticateToken, requirePermission('roles.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) return res.status(400).json({ error: 'Неверный ID задачи' }); const { roleId } = req.body; if (!roleId) return res.status(400).json({ error: 'Укажите roleId' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); const role = await storage.getRole(parseInt(roleId), req.organizationId!); if (!role) return res.status(404).json({ error: 'Роль не найдена' }); const taskRole = await storage.addTaskRole(taskId, parseInt(roleId), req.organizationId!); const editorNameRole = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'task.updated', fieldName: `Роль назначена: ${role.name}`, oldValue: null, newValue: role.name, changedBy: req.user?.id ?? null, changedByName: editorNameRole, }).catch((e: unknown) => console.error('Audit log role add error:', e)); res.json({ success: true, taskRole }); } catch (err) { console.error('Add task role error:', err); res.status(500).json({ error: 'Ошибка при добавлении роли задачи' }); } } ); router.delete('/api/tasks/:id/roles/:roleId', authenticateToken, requirePermission('roles.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); const roleId = parseInt(req.params.roleId); if (isNaN(taskId) || isNaN(roleId)) return res.status(400).json({ error: 'Неверные параметры' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); const roleForAudit = await storage.getRole(roleId, req.organizationId!); await storage.removeTaskRole(taskId, roleId, req.organizationId!); const editorNameRole = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'task.updated', fieldName: `Роль снята: ${roleForAudit?.name ?? roleId}`, oldValue: roleForAudit?.name ?? String(roleId), newValue: null, changedBy: req.user?.id ?? null, changedByName: editorNameRole, }).catch((e: unknown) => console.error('Audit log role remove error:', e)); res.json({ success: true }); } catch (err) { console.error('Remove task role error:', err); res.status(500).json({ error: 'Ошибка при удалении роли задачи' }); } } ); // === TASK ASSIGNEES === router.get('/api/tasks/:id/assignees', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) return res.status(400).json({ error: 'Неверный ID задачи' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); const assignees = await storage.getTaskAssignees(taskId, req.organizationId!); res.json({ success: true, assignees }); } catch (err) { console.error('Get task assignees error:', err); res.status(500).json({ error: 'Ошибка при получении ответственных' }); } } ); router.post('/api/tasks/:id/assignees', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) return res.status(400).json({ error: 'Неверный ID задачи' }); const { userId } = req.body; if (!userId) return res.status(400).json({ error: 'Укажите userId' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); const orgUsers = await storage.getUsersByOrganization(req.organizationId!); const targetUser = orgUsers.find(u => u.id === parseInt(userId)); if (!targetUser) return res.status(404).json({ error: 'Пользователь не найден' }); const assignee = await storage.addTaskAssignee(taskId, parseInt(userId), req.organizationId!); // Sync legacy assignedTo column and trigger auto-transitions await storage.updateTask(taskId, req.organizationId!, { assignedTo: parseInt(userId) }); tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`); const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null }); const editorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; const userName = (`${targetUser.firstName || ''} ${targetUser.middleName || ''} ${targetUser.lastName || ''}`.trim() || targetUser.email); storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'task.updated', fieldName: 'Ответственный добавлен', oldValue: null, newValue: userName, changedBy: req.user?.id ?? null, changedByName: editorName, }).catch((e: unknown) => console.error('Audit log assignee add error:', e)); const refreshedTask = await storage.getTask(taskId, req.organizationId!); // Уведомление назначенному пользователю if (refreshedTask) { notifyTaskAssigned(refreshedTask, parseInt(userId), req.user?.id ?? null, req.organizationId!) .catch((err) => console.error('[AssigneeAdd] notifyTaskAssigned error:', err)); } eventBus.publishEvent({ type: 'task_updated', organizationId: req.organizationId!, data: { taskId, formId: refreshedTask?.formId, task: refreshedTask } }); res.json({ success: true, assignee, task: refreshedTask, autoTransition: autoResult.changed }); } catch (err) { console.error('Add task assignee error:', err); res.status(500).json({ error: 'Ошибка при добавлении ответственного' }); } } ); router.delete('/api/tasks/:id/assignees/:userId', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); const userId = parseInt(req.params.userId); if (isNaN(taskId) || isNaN(userId)) return res.status(400).json({ error: 'Неверные параметры' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); const hasAccess = await storage.canUserAccessTask( taskId, req.user!.id, req.organizationId!, req.user!.appRole ); if (!hasAccess) { return res.status(403).json({ error: 'Нет доступа к этой задаче' }); } const orgUsers = await storage.getUsersByOrganization(req.organizationId!); const targetUser = orgUsers.find(u => u.id === userId); await storage.removeTaskAssignee(taskId, userId, req.organizationId!); // Sync legacy assignedTo column and trigger auto-transitions const remainingAssignees = await storage.getTaskAssignees(taskId, req.organizationId!); const newAssignedTo = remainingAssignees.length > 0 ? remainingAssignees[0].userId : null; await storage.updateTask(taskId, req.organizationId!, { assignedTo: newAssignedTo }); tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`); const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null }); const editorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; const userName = targetUser ? (`${targetUser.firstName || ''} ${targetUser.middleName || ''} ${targetUser.lastName || ''}`.trim() || targetUser.email) : String(userId); storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'task.updated', fieldName: 'Ответственный снят', oldValue: userName, newValue: null, changedBy: req.user?.id ?? null, changedByName: editorName, }).catch((e: unknown) => console.error('Audit log assignee remove error:', e)); const refreshedTask = await storage.getTask(taskId, req.organizationId!); // Уведомление новому ответственному (если после удаления назначен другой) if (refreshedTask && newAssignedTo) { notifyTaskAssigned(refreshedTask, newAssignedTo, req.user?.id ?? null, req.organizationId!) .catch((err) => console.error('[AssigneeRemove] notifyTaskAssigned error:', err)); } eventBus.publishEvent({ type: 'task_updated', organizationId: req.organizationId!, data: { taskId, formId: refreshedTask?.formId, task: refreshedTask } }); res.json({ success: true, task: refreshedTask, autoTransition: autoResult.changed }); } catch (err) { console.error('Remove task assignee error:', err); res.status(500).json({ error: 'Ошибка при удалении ответственного' }); } } ); // === TASK USER ACCESS === router.get('/api/tasks/:id/access', authenticateToken, requirePermission('roles.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) return res.status(400).json({ error: 'Неверный ID задачи' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); const roles = await storage.getTaskRoles(taskId, req.organizationId!); const accessRecords = await storage.getTaskUserAccess(taskId, req.organizationId!); const rolesTree = roles.map(tr => ({ roleId: tr.roleId, roleName: tr.role.name, parentId: tr.role.parentId, users: accessRecords.filter(a => a.roleId === tr.roleId || (a.grantedVia === 'supervisor' && a.roleId === tr.roleId)), })); const assignedRoleIds = new Set(roles.map(r => r.roleId)); const directUsers = accessRecords.filter(a => a.grantedVia === 'direct' || !assignedRoleIds.has(a.roleId as number) ); res.json({ success: true, roles: rolesTree, directUsers, access: accessRecords }); } catch (err) { console.error('Get task access error:', err); res.status(500).json({ error: 'Ошибка при получении доступов задачи' }); } } ); router.delete('/api/tasks/:id/access/:userId', authenticateToken, requirePermission('roles.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); const userId = parseInt(req.params.userId); if (isNaN(taskId) || isNaN(userId)) return res.status(400).json({ error: 'Неверные параметры' }); const task = await storage.getTask(taskId, req.organizationId!); if (!task) return res.status(404).json({ error: 'Задача не найдена' }); await storage.revokeTaskUserAccess(taskId, userId, req.organizationId!); res.json({ success: true }); } catch (err) { console.error('Revoke task access error:', err); res.status(500).json({ error: 'Ошибка при отзыве доступа' }); } } ); // === TASK RELATIONS === /** * @swagger * /api/tasks/{id}/relations: * post: * tags: [Tasks] * summary: Привязать задачу (создать связь) * description: > * Создаёт ручную связь между двумя задачами через таблицу task_relations. * Поле `fieldId` у созданной связи будет NULL (связь из чата/ручная). * Повторный вызов с теми же параметрами идемпотентен (upsert). * parameters: * - in: path * name: id * required: true * schema: * type: integer * description: ID текущей (базовой) задачи * requestBody: * required: true * content: * application/json: * schema: * type: object * required: [relatedTaskId, type] * properties: * relatedTaskId: * type: integer * type: * type: string * enum: [parent, child] * responses: * 200: * description: Связь успешно создана * 400: * description: Неверные параметры запроса * 404: * description: Одна из задач не найдена */ router.post('/api/tasks/:id/relations', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Неверный ID задачи' }); } const { relatedTaskId, type } = req.body; if (!relatedTaskId || !['parent', 'child'].includes(type)) { return res.status(400).json({ success: false, error: 'Укажите relatedTaskId и type (parent|child)' }); } const relId = parseInt(String(relatedTaskId)); if (isNaN(relId)) { return res.status(400).json({ success: false, error: 'Неверный relatedTaskId' }); } const [currentTask, relatedTask] = await Promise.all([ storage.getTask(taskId, req.organizationId!), storage.getTask(relId, req.organizationId!), ]); if (!currentTask) return res.status(404).json({ success: false, error: 'Задача не найдена' }); if (!relatedTask) return res.status(404).json({ success: false, error: 'Связываемая задача не найдена' }); let relation; if (type === 'parent') { relation = await storage.upsertTaskRelation({ parentTaskId: relId, childTaskId: taskId, fieldId: null, organizationId: req.organizationId!, }); } else { relation = await storage.upsertTaskRelation({ parentTaskId: taskId, childTaskId: relId, fieldId: null, organizationId: req.organizationId!, }); } res.json({ success: true, relation }); } catch (error) { console.error('Create task relation error:', error); res.status(500).json({ success: false, error: 'Ошибка при создании связи' }); } } ); }