- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
387 lines
18 KiB
TypeScript
387 lines
18 KiB
TypeScript
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<typeof import("express").Router>): 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: 'Ошибка при создании связи' });
|
||
}
|
||
}
|
||
);
|
||
}
|