Files
iistwin/server/routes/task-roles.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

387 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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: 'Ошибка при создании связи' });
}
}
);
}