import { Router } from "express"; import crypto from "crypto"; import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { validateRequest } from "../middleware/validation.middleware"; import { insertTaskSchema, idempotencyKeys, taskFieldValues, taskAuditLog } from "@shared/schema"; import { notificationService } from "../services/notification.service"; import { buildSystemFieldValues, buildTableRowMap, commitPendingUploads, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared"; import { tasksMinimalCache } from "../utils/cache"; import { indexTaskAsync, parseOfflineTimestamp } from "./task-helpers"; import { generateContractNumber } from "../services/contract-number.service"; import { evaluateAutoTransitions } from "../utils/auto-transitions"; import { notifyTaskAssigned } from "../utils/notifyAssignee"; import { validateRequiredFields } from "../utils/validate-required-fields"; import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service"; import { runAutomationsByTrigger, type AutomationRunResult } from "./automation.routes"; import { normalizeFieldValueForStorage } from "../utils/normalize-field-value"; import { normalizeFileFieldUrls } from "../utils/upload"; import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor"; import { db, withTenant } from "../db"; import { eq, and, sql } from "drizzle-orm"; function normalizeCustomFields( customFields: Record | undefined, formFields: Array<{ id: number; code: string }> ): Record { if (!customFields || typeof customFields !== 'object') return {}; const codeToId = new Map(formFields.map(f => [f.code, f.id])); const result: Record = {}; for (const [key, value] of Object.entries(customFields)) { if (key.startsWith('field_') && codeToId.has(key)) { result[`customField_${codeToId.get(key)}`] = value; } else { result[key] = value; } } return result; } export function registerTaskCrudWriteRoutes(router: ReturnType): void { router.post('/api/forms/:id/tasks', validateRequest(insertTaskSchema.omit({ createdBy: true })), async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) { return res.status(400).json({ success: false, error: 'Неверный ID формы' }); } const existingForm = await storage.getForm(formId, req.organizationId!); if (!existingForm) { return res.status(404).json({ success: false, error: 'Форма не найдена' }); } if (req.user) { const canAccessForm = await storage.canUserAccessForm(req.user.id, formId, req.organizationId!, 'participate'); if (!canAccessForm) { return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' }); } } // Проверки для авторизации по API-ключу (JWT-путь не меняется) const apiKeyErrCreate = checkApiKeyWriteAccess(req, formId); if (apiKeyErrCreate) { return res.status(403).json({ success: false, error: apiKeyErrCreate }); } const { customFields, dueDate, ...baseTaskData } = req.body; let parsedDueDate = null; if (dueDate && dueDate !== '') { if (dueDate instanceof Date) { parsedDueDate = dueDate; } else if (typeof dueDate === 'string') { const isoDate = new Date(dueDate); if (!isNaN(isoDate.getTime())) { parsedDueDate = isoDate; } else { const parts = dueDate.split('.'); if (parts.length === 3) { const [day, month, year] = parts; parsedDueDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); } } } } // Extract parentTaskId from contract-number field before creating task let parentTaskId = baseTaskData.parentTaskId || null; const formFieldsForNorm = await storage.getFormFields(formId, req.organizationId!); // Validate conditional required fields before creating task const formStatusesForValidation = await storage.getFormStatuses(formId, req.organizationId!); const validationErrors = await validateRequiredFields({ task: { currentStatusId: baseTaskData.currentStatusId, formId }, formFields: formFieldsForNorm, customFields, organizationId: req.organizationId!, storage, isCreation: true, statuses: formStatusesForValidation, }); if (validationErrors.length > 0) { return res.status(400).json({ success: false, error: validationErrors.join('\n') }); } const normalizedCustomFieldsPre = normalizeCustomFields(customFields, formFieldsForNorm); // Pre-create automation hook: allows automations to block task creation const preCreateResults = await runAutomationsByTrigger(req.organizationId!, 'task.before_create', { formId, task: { formId, title: baseTaskData.title, currentStatusId: baseTaskData.currentStatusId, dueDate: parsedDueDate, customFields: normalizedCustomFieldsPre, }, }); for (const run of preCreateResults) { const result = run.result as { allow?: boolean; error?: string; activeRentalTaskId?: number; assignedToName?: string | null } | undefined; if (result && result.allow === false) { return res.status(400).json({ success: false, error: result.error || 'Создание задачи запрещено автоматизацией', automationId: run.automationId, activeRentalTaskId: result.activeRentalTaskId, assignedToName: result.assignedToName, }); } } if (Object.keys(normalizedCustomFieldsPre).length > 0) { for (const [fieldKey, value] of Object.entries(normalizedCustomFieldsPre)) { const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/); if (fieldIdMatch && value !== undefined && value !== '') { const fieldId = parseInt(fieldIdMatch[1]); const field = formFieldsForNorm.find(f => f.id === fieldId); if (field?.type === 'contract-number') { const parsed = typeof value === 'string' ? JSON.parse(value) : value; if (parsed?.legalEntityTaskId) { parentTaskId = parsed.legalEntityTaskId; break; } } } } } // Автор: пользователь (JWT) или владелец API-ключа const createdByUserId = req.user?.id ?? req.apiKey?.createdBy; if (!createdByUserId) { return res.status(401).json({ success: false, error: 'Не удалось определить автора задачи' }); } const taskData = { ...baseTaskData, formId, organizationId: req.organizationId!, createdBy: createdByUserId, dueDate: parsedDueDate, parentTaskId, }; let task = await storage.createTask(taskData); tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`); // Sync the primary assignee into task_assignees so TaskDetail can display it if (task.assignedTo) { await storage.addTaskAssignee(task.id, task.assignedTo, req.organizationId!).catch(() => {}); } // Auto-assign task based on transition rules from current status if (!task.assignedTo && task.currentStatusId) { const transitions = await storage.getStatusTransitions(formId, req.organizationId!); const outgoingTransitions = transitions.filter(t => t.fromStatusId === task.currentStatusId); let targetUserId: number | null = null; for (const transition of outgoingTransitions) { if (transition.assigneeUserId) { targetUserId = transition.assigneeUserId; break; } if (transition.assigneeConditions) { const conditions = transition.assigneeConditions as Record; // New format: first user assignee if (conditions.assignees && Array.isArray(conditions.assignees)) { const firstUser = conditions.assignees.find((a: any) => a.type === 'user'); if (firstUser?.id) { targetUserId = firstUser.id; break; } } // Old format fallback if (conditions.fallbackAssigneeUserId) { targetUserId = conditions.fallbackAssigneeUserId as number; break; } // Backward-compat direct assigneeId if (conditions.assigneeId) { targetUserId = conditions.assigneeId as number; break; } } } if (targetUserId) { await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId }); await storage.addTaskAssignee(task.id, targetUserId, req.organizationId!).catch(() => {}); // Refresh local task object so the code below sees the new assignee const refreshed = await storage.getTask(task.id, req.organizationId!); if (refreshed) task = refreshed; } } const creatorActor = await resolveRestActor(req, req.organizationId!); storage.addTaskAuditLog({ taskId: task.id, organizationId: req.organizationId!, action: 'task.created', changedBy: creatorActor.changedBy, changedByName: creatorActor.changedByName, botId: creatorActor.botId, metadata: { title: task.title, ...(creatorActor.source ? { source: creatorActor.source } : {}) }, }).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); }); if (customFields && typeof customFields === 'object') { const formFields = await storage.getFormFields(formId, req.organizationId!); const normalizedCustomFields = normalizeCustomFields(customFields, formFields); const fieldMap = new Map(formFields.map(f => [f.id, f])); const fieldValues = []; for (const [fieldKey, value] of Object.entries(normalizedCustomFields)) { const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/); if (fieldIdMatch && value !== undefined && value !== '') { const fieldId = parseInt(fieldIdMatch[1]); const field = fieldMap.get(fieldId); if (!field) { console.warn(`Field ${fieldId} not found for form ${formId}`); continue; } if (field.type === 'file' && value != null) { const fileArr = Array.isArray(value) ? value : []; if (field.maxFileCount != null && fileArr.length > field.maxFileCount) { return res.status(400).json({ success: false, error: `Поле В«${field.name}В»: превышено максимальное количество файлов (${field.maxFileCount})`, }); } if (field.maxFileSizeMB != null) { const totalBytes = fileArr.reduce((sum: number, f: any) => sum + (Number(f.size) || 0), 0); const limitBytes = field.maxFileSizeMB * 1024 * 1024; if (totalBytes > limitBytes) { return res.status(400).json({ success: false, error: `Поле В«${field.name}В»: суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)`, }); } } } // Auto-generate contract number for 'mine' mode let finalValue = normalizeFieldValueForStorage(value, field.type); // Нормализация self-URL в file-полях: абсолютные https://.../api/files/ → относительные if (field.type === 'file') { finalValue = normalizeFileFieldUrls(finalValue) as typeof finalValue; } if (field.type === 'contract-number') { const parsed = typeof value === 'string' ? JSON.parse(value) : value; if (parsed && parsed.mode === 'mine' && (!parsed.value || parsed.value === '')) { const config = (field.options as any)?.contractNumber; if (config && parsed.legalEntityTaskId) { const generated = await generateContractNumber({ organizationId: req.organizationId!, formId, legalEntityTaskId: parsed.legalEntityTaskId, prefixFieldCode: config.prefixFieldCode, startNumber: config.startNumber ?? 1, sequencePadding: config.sequencePadding ?? 1, }); finalValue = JSON.stringify({ mode: 'mine', value: generated, legalEntityTaskId: parsed.legalEntityTaskId }); } } } const valueData = { taskId: task.id, fieldId, formId, value: finalValue }; const createdValue = await storage.createTaskFieldValue(valueData); fieldValues.push(createdValue); } } } let finalTask = task; if (existingForm.titleTemplate) { const [allFormFields, savedValues, titleStatuses, titleUsers, titleRoles] = await Promise.all([ storage.getFormFields(formId, req.organizationId!), storage.getTaskFieldValues(task.id, req.organizationId!), storage.getFormStatuses(formId, req.organizationId!), storage.getUsersByOrganization(req.organizationId!), storage.getRoles(req.organizationId!), ]); const fieldCodeMap = new Map(allFormFields.map(f => [f.code, f])); const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value])); const usersMap = new Map(titleUsers.map(u => [u.id, u])); const rolesMap = new Map(titleRoles.map(r => [r.id, r])); const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: titleStatuses, usersMap }); const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, allFormFields, req.organizationId!); const tableRowMap = await buildTableRowMap(allFormFields, req.organizationId!); const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => { if (sysValues.has(code)) return sysValues.get(code)!; const field = fieldCodeMap.get(code); if (!field) return ''; const val = valueByFieldId.get(field.id); if (val === null || val === undefined) return ''; if (val === '__auto_prolongation__' && (field.type === 'date' || field.type === 'datetime')) { return field.autoProlongationLabel || 'Автопролонгация'; } return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap, tableRowMap }, field.options); }); finalTask = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle }); } if (finalTask.assignedTo && finalTask.assignedTo !== req.user?.id) { notifyTaskAssigned(finalTask, finalTask.assignedTo, req.user?.id ?? null, req.organizationId!) .catch((err) => console.error('[TaskCreate] notifyTaskAssigned error:', err)); } if (customFields && req.user?.id) { const formFieldsForUpload = await storage.getFormFields(formId, req.organizationId!); const normalizedUploadFields = normalizeCustomFields(customFields, formFieldsForUpload); for (const [fieldKey, value] of Object.entries(normalizedUploadFields)) { const match = fieldKey.match(/^customField_(\d+)$/); if (match && Array.isArray(value)) { const fid = parseInt(match[1]); const committedUrls = (value as Array>).map(f => String(f.url)).filter(Boolean); if (committedUrls.length > 0) { commitPendingUploads(req.user!.id, fid, committedUrls); } } } } // Evaluate auto-transitions based on entry conditions on statuses const autoResult = await evaluateAutoTransitions(finalTask.id, req.organizationId!, { triggeredBy: req.user?.id ?? null }); if (autoResult.changed) { const refreshed = await storage.getTask(finalTask.id, req.organizationId!); if (refreshed) finalTask = refreshed; } // Notify other users in real-time that a new task was created eventBus.publishEvent({ type: 'task_updated', organizationId: req.organizationId!, data: { taskId: finalTask.id, formId: finalTask.formId, task: finalTask } }); indexTaskAsync(finalTask.id, req.organizationId!).catch(() => {}); res.status(201).json({ success: true, message: 'Задача создана', task: finalTask }); } catch (error) { console.error('Create task error:', error); res.status(500).json({ success: false, error: 'Ошибка при создании задачи' }); } } ); // Check whether a task can be created given the provided prefill data. // Runs task.before_create automations without actually creating a task. router.post('/api/forms/:id/tasks/check-create', async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) { return res.status(400).json({ success: false, error: 'Неверный ID формы' }); } const existingForm = await storage.getForm(formId, req.organizationId!); if (!existingForm) { return res.status(404).json({ success: false, error: 'Форма не найдена' }); } const canAccessForm = await storage.canUserAccessForm(req.user!.id, formId, req.organizationId!, 'participate'); if (!canAccessForm) { return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' }); } const { customFields, title, dueDate, currentStatusId } = req.body; let parsedDueDate: Date | null = null; if (dueDate && dueDate !== '') { if (dueDate instanceof Date) { parsedDueDate = dueDate; } else if (typeof dueDate === 'string') { const isoDate = new Date(dueDate); if (!isNaN(isoDate.getTime())) { parsedDueDate = isoDate; } else { const parts = dueDate.split('.'); if (parts.length === 3) { const [day, month, year] = parts; parsedDueDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); } } } } const formFieldsForNorm = await storage.getFormFields(formId, req.organizationId!); const normalizedCustomFields = normalizeCustomFields(customFields, formFieldsForNorm); let resolvedCurrentStatusId: number | undefined = currentStatusId; if (!resolvedCurrentStatusId) { const formStatuses = await storage.getFormStatuses(formId, req.organizationId!); const initialStatus = formStatuses.find(s => s.isInitial); resolvedCurrentStatusId = initialStatus?.id; } if (!resolvedCurrentStatusId) { return res.status(400).json({ success: false, error: 'Не найден начальный статус для формы' }); } const preCreateResults = await runAutomationsByTrigger(req.organizationId!, 'task.before_create', { formId, task: { formId, title: title || existingForm.name || 'Новая запись', currentStatusId: resolvedCurrentStatusId, dueDate: parsedDueDate, customFields: normalizedCustomFields, }, }); for (const run of preCreateResults) { const result = run.result as { allow?: boolean; error?: string; activeRentalTaskId?: number; assignedToName?: string | null } | undefined; if (result && result.allow === false) { return res.status(200).json({ success: true, allowed: false, automationId: run.automationId, error: result.error || 'Создание задачи запрещено автоматизацией', activeRentalTaskId: result.activeRentalTaskId, assignedToName: result.assignedToName, }); } } return res.status(200).json({ success: true, allowed: true }); } catch (error) { console.error('Check create error:', error); return res.status(500).json({ success: false, error: 'Ошибка при проверке создания задачи' }); } }); router.get('/api/tasks/:id', 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 task = await storage.getTask(taskId, req.organizationId!); if (!task) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } const hasAccess = await storage.canUserAccessTask( taskId, req.user!.id, req.organizationId!, req.user!.appRole ); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' }); } res.json({ success: true, task }); } catch (error) { console.error('Get task error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении задачи' }); } } ); router.get('/api/tasks/:id/detail', 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 detail = await storage.getTaskDetail(taskId, req.organizationId!); if (!detail) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } const hasAccess = await storage.canUserAccessTask( taskId, req.user!.id, req.organizationId!, req.user!.appRole ); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' }); } res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate'); res.json({ success: true, ...detail }); } catch (error) { console.error('Get task detail error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении данных задачи' }); } } ); router.put('/api/tasks/:id', async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Неверный ID задачи' }); } const existingTask = await storage.getTask(taskId, req.organizationId!); if (!existingTask) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } { const hasAccess = await storage.canUserAccessTask( taskId, req.user!.id, req.organizationId!, req.user!.appRole ); if (!hasAccess) { return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' }); } } const updateBody = { ...req.body }; const clientUpdatedAt = updateBody.updatedAt; delete updateBody.updatedAt; // Кастомные поля сохраняются через отдельные endpoints; исключаем из updateBody, // чтобы они не мешали валидации и не попадали в storage.updateTask. delete updateBody.customFields; if (updateBody.dueDate && typeof updateBody.dueDate === 'string') { updateBody.dueDate = new Date(updateBody.dueDate); } // If the request was replayed from offline queue, honour the client's // original edit timestamp so the audit log shows when the user actually // made the change, not when the sync happened. const offlineEnqueuedAt = parseOfflineTimestamp(req); // Validate conditional required fields before updating task system fields const putFormFields = await storage.getFormFields(existingTask.formId, req.organizationId!); const putExistingValues = await storage.getTaskFieldValues(taskId, req.organizationId!); const putFormStatuses = await storage.getFormStatuses(existingTask.formId, req.organizationId!); const putValidationErrors = await validateRequiredFields({ task: { ...existingTask, ...updateBody }, formFields: putFormFields, existingFieldValues: putExistingValues, organizationId: req.organizationId!, storage, statuses: putFormStatuses, }); if (putValidationErrors.length > 0) { return res.status(400).json({ success: false, error: putValidationErrors.join('\n') }); } let updatedTask: typeof existingTask | null = null; if (clientUpdatedAt !== undefined && clientUpdatedAt !== null) { const clientDate = new Date(clientUpdatedAt); if (isNaN(clientDate.getTime())) { return res.status(400).json({ success: false, error: 'Неверный формат поля updatedAt' }); } updatedTask = await storage.updateTaskIfNotModified(taskId, req.organizationId!, updateBody, clientDate); if (!updatedTask) { const currentTask = await storage.getTask(taskId, req.organizationId!); return res.status(409).json({ success: false, error: 'Задача была изменена другим пользователем. Пожалуйста, обновите страницу.', currentUpdatedAt: currentTask?.updatedAt ?? existingTask.updatedAt, }); } } else { updatedTask = await storage.updateTask(taskId, req.organizationId!, updateBody); } tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`); // Gantt cascade shift for dueDate changes if ('dueDate' in updateBody && updateBody.dueDate !== undefined) { const oldDueDate = existingTask.dueDate; const newDueDate = updateBody.dueDate as Date | null; if (oldDueDate && newDueDate && !isNaN(newDueDate.getTime())) { const deltaMs = calculateDateShift(oldDueDate, newDueDate); if (deltaMs !== 0) { shiftRelatedTasks(taskId, deltaMs, req.organizationId!, { triggeredByUserId: req.user?.id }) .catch((err: any) => console.error('Gantt cascade shift error (dueDate):', err)); } } } const editorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; const resolveUserName = async (userId: number | null | undefined): Promise => { if (!userId) return null; const orgUsers = await storage.getUsersByOrganization(req.organizationId!); const u = orgUsers.find(x => x.id === userId); if (!u) return String(userId); return (`${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() || u.email); }; const trackedFields: Array<{ key: 'title' | 'description' | 'assignedTo' | 'dueDate'; label: string }> = [ { key: 'title', label: 'Заголовок' }, { key: 'description', label: 'Описание' }, { key: 'assignedTo', label: '�?сполнитель' }, { key: 'dueDate', label: 'Срок' }, ]; for (const { key, label } of trackedFields) { if (key in updateBody) { const oldVal = existingTask[key]; const newVal = updateBody[key]; const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal); const newStr = newVal === null || newVal === undefined ? '' : String(newVal); if (oldStr !== newStr) { let auditOldValue: string | number | null = oldVal as string | number | null; let auditNewValue: string | number | null = newVal as string | number | null; if (key === 'assignedTo') { const [oldName, newName] = await Promise.all([ resolveUserName(oldVal as number | null), resolveUserName(newVal as number | null), ]); auditOldValue = oldName; auditNewValue = newName; } storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'task.updated', fieldName: label, oldValue: auditOldValue, newValue: auditNewValue, changedBy: req.user?.id ?? null, changedByName: editorName, ...(offlineEnqueuedAt ? { createdAt: offlineEnqueuedAt } : {}), }).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); }); } } } // Уведомление новому ответственному, если assignedTo изменился через PUT if ( updatedTask && 'assignedTo' in updateBody && updateBody.assignedTo !== undefined && updateBody.assignedTo !== existingTask.assignedTo ) { const newAssigneeId = updateBody.assignedTo as number | null; if (newAssigneeId) { notifyTaskAssigned(updatedTask, newAssigneeId, req.user?.id ?? null, req.organizationId!) .catch((err) => console.error('[TaskUpdate] notifyTaskAssigned error:', err)); } } if (req.user?.id) { storage.recordTaskInteractionAuto(taskId, req.user.id, req.organizationId!) .catch((err: unknown) => { console.error('recordTaskInteraction error:', err); }); } indexTaskAsync(taskId, req.organizationId!).catch(() => {}); // Evaluate auto-transitions after task update (e.g. assignedTo changed) const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null }); if (autoResult.changed) { const refreshed = await storage.getTask(taskId, req.organizationId!); if (refreshed) updatedTask = refreshed; } eventBus.publishEvent({ type: 'task_updated', organizationId: req.organizationId!, data: { taskId, formId: updatedTask.formId, task: updatedTask } }); res.json({ success: true, message: 'Задача обновлена', task: updatedTask }); } catch (error) { console.error('Update task error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении задачи' }); } } ); router.delete('/api/tasks/:id', requirePermission('tasks.edit_all'), async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); if (isNaN(taskId)) { return res.status(400).json({ success: false, error: 'Неверный ID задачи' }); } const existingTask = await storage.getTask(taskId, req.organizationId!); if (!existingTask) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } await storage.deleteTask(taskId, req.organizationId!); tasksMinimalCache.invalidate(`tasks:${req.organizationId}:minimal:`); res.json({ success: true, message: 'Задача удалена' }); } catch (error) { console.error('Delete task error:', error); res.status(500).json({ success: false, error: 'Ошибка при удалении задачи' }); } }); router.post('/api/forms/:id/tasks/batch', async (req: AuthenticatedRequest, res) => { try { const formId = parseInt(req.params.id); if (isNaN(formId)) { return res.status(400).json({ success: false, error: 'Неверный ID формы' }); } const existingForm = await storage.getForm(formId, req.organizationId!); if (!existingForm) { return res.status(404).json({ success: false, error: 'Форма не найдена' }); } const { tasks: taskList, idempotencyKey } = req.body as { tasks?: unknown[]; idempotencyKey?: string }; if (!Array.isArray(taskList) || taskList.length === 0) { return res.status(400).json({ success: false, error: 'Необходимо передать массив задач' }); } if (taskList.length > 100) { return res.status(400).json({ success: false, error: 'Максимум 100 задач за один batch' }); } const formStatusesList = await storage.getFormStatuses(formId, req.organizationId!); const initialStatus = formStatusesList.find(s => s.isInitial); if (!initialStatus) { return res.status(400).json({ success: false, error: 'Не найден начальный статус для формы' }); } // Pre-load common data once for all tasks in the batch const [formFields, transitions, users, roles] = await Promise.all([ storage.getFormFields(formId, req.organizationId!), storage.getStatusTransitions(formId, req.organizationId!), storage.getUsersByOrganization(req.organizationId!), storage.getRoles(req.organizationId!), ]); const fieldMap = new Map(formFields.map(f => [f.id, f])); const usersMap = new Map(users.map(u => [u.id, u])); const rolesMap = new Map(roles.map(r => [r.id, r])); const fieldCodeMap = new Map(formFields.map(f => [f.code, f])); // Pre-build table row map once (used by title template and auto-transitions) const tableRowMap = await buildTableRowMap(formFields, req.organizationId!); interface PreparedTask { taskData: any; normalizedCustomFields: Record; } // ── Phase 1: validate and run pre-create automations for ALL tasks before any DB write ── const preparedTasks: PreparedTask[] = []; for (let i = 0; i < taskList.length; i++) { const taskBody = taskList[i] as any; const { customFields, dueDate, ...baseTaskData } = taskBody; let parsedDueDate: Date | null = null; if (dueDate && dueDate !== '') { if (dueDate instanceof Date) { parsedDueDate = dueDate; } else if (typeof dueDate === 'string') { const isoDate = new Date(dueDate); if (!isNaN(isoDate.getTime())) { parsedDueDate = isoDate; } else { const parts = dueDate.split('.'); if (parts.length === 3) { const [day, month, year] = parts; parsedDueDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)); } } } } const normalizedCustomFields = normalizeCustomFields(customFields, formFields); const validationErrors = await validateRequiredFields({ task: { currentStatusId: initialStatus.id, formId }, formFields, customFields: normalizedCustomFields, organizationId: req.organizationId!, storage, isCreation: true, statuses: formStatusesList, users, roles, }); if (validationErrors.length > 0) { return res.status(400).json({ success: false, error: `Ошибка в элементе ${i + 1}: ${validationErrors.join('; ')}`, }); } const preCreateResults = await runAutomationsByTrigger(req.organizationId!, 'task.before_create', { formId, task: { formId, title: baseTaskData.title, currentStatusId: initialStatus.id, dueDate: parsedDueDate, customFields: normalizedCustomFields, }, }); for (const run of preCreateResults) { const result = run.result as { allow?: boolean; error?: string; activeRentalTaskId?: number; assignedToName?: string | null } | undefined; if (result && result.allow === false) { return res.status(400).json({ success: false, error: `Элемент ${i + 1}: ${result.error || 'Создание задачи запрещено автоматизацией'}`, automationId: run.automationId, activeRentalTaskId: result.activeRentalTaskId, assignedToName: result.assignedToName, }); } } const taskData = { ...baseTaskData, formId, organizationId: req.organizationId!, createdBy: req.user!.id, dueDate: parsedDueDate, currentStatusId: initialStatus.id, }; preparedTasks.push({ taskData, normalizedCustomFields }); } const payloadHash = crypto.createHash('sha256').update(JSON.stringify(taskList)).digest('hex'); const endpointPath = `/api/forms/${formId}/tasks/batch`; const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; // ── Phase 2: atomic creation inside one tenant transaction ── const txResult = await withTenant(req.organizationId!, async () => { // Idempotency check (inside transaction so it shares RLS context) if (idempotencyKey && idempotencyKey.length <= 255) { const existing = await db .select() .from(idempotencyKeys) .where( and( eq(idempotencyKeys.organizationId, req.organizationId!), eq(idempotencyKeys.key, idempotencyKey), sql`${idempotencyKeys.expiresAt} > NOW()` ) ) .limit(1); if (existing.length > 0) { const stored = existing[0]; if (stored.payloadHash !== payloadHash) { throw new Error('Idempotency key used with different payload'); } const resp = stored.responseJson as { created?: number; taskIds?: number[] }; return { cached: true as const, created: resp.created ?? 0, taskIds: resp.taskIds ?? [] }; } } const createdIds: number[] = []; const fieldValuesToInsert: any[] = []; const auditLogsToInsert: any[] = []; const assigneesToAdd: Array<{ taskId: number; userId: number }> = []; const autoTransitionTasks: Array<{ taskId: number; assignedTo?: number | null }> = []; for (let i = 0; i < preparedTasks.length; i++) { const { taskData, normalizedCustomFields } = preparedTasks[i]; let task = await storage.createTask(taskData); if (task.assignedTo) { assigneesToAdd.push({ taskId: task.id, userId: task.assignedTo }); } // Collect field values for batch insert if (normalizedCustomFields && typeof normalizedCustomFields === 'object') { for (const [fieldKey, value] of Object.entries(normalizedCustomFields)) { const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/); if (fieldIdMatch && value !== undefined && value !== '') { const fieldId = parseInt(fieldIdMatch[1]); const field = fieldMap.get(fieldId); if (!field) continue; fieldValuesToInsert.push({ taskId: task.id, fieldId, formId, value: normalizeFieldValueForStorage(value, field.type), }); } } } if (existingForm.titleTemplate) { const savedValues = await storage.getTaskFieldValues(task.id, req.organizationId!); const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value])); const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: formStatusesList, usersMap }); const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!); const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_: string, code: string) => { if (sysValues.has(code)) return sysValues.get(code)!; const field = fieldCodeMap.get(code); if (!field) return ''; const val = valueByFieldId.get(field.id); if (val === null || val === undefined) return ''; if (val === '__auto_prolongation__' && (field.type === 'date' || field.type === 'datetime')) { return field.autoProlongationLabel || 'Автопролонгация'; } return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap, tableRowMap }, field.options); }); task = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle }); } // Auto-assign task based on transition rules from current status if (!task.assignedTo && task.currentStatusId) { const outgoingTransitions = transitions.filter(t => t.fromStatusId === task.currentStatusId); let targetUserId: number | null = null; for (const transition of outgoingTransitions) { if (transition.assigneeUserId) { targetUserId = transition.assigneeUserId; break; } if (transition.assigneeConditions) { const conditions = transition.assigneeConditions as Record; if (conditions.assignees && Array.isArray(conditions.assignees)) { const firstUser = conditions.assignees.find((a: any) => a.type === 'user'); if (firstUser?.id) { targetUserId = firstUser.id; break; } } if (conditions.fallbackAssigneeUserId) { targetUserId = conditions.fallbackAssigneeUserId as number; break; } if (conditions.assigneeId) { targetUserId = conditions.assigneeId as number; break; } } } if (targetUserId) { await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId }); assigneesToAdd.push({ taskId: task.id, userId: targetUserId }); const refreshed = await storage.getTask(task.id, req.organizationId!); if (refreshed) task = refreshed; } } auditLogsToInsert.push({ taskId: task.id, organizationId: req.organizationId!, action: 'task.created', changedBy: req.user?.id ?? null, changedByName: creatorName, metadata: { title: task.title }, }); createdIds.push(task.id); autoTransitionTasks.push({ taskId: task.id, assignedTo: task.assignedTo }); } // Batch insert all custom field values if (fieldValuesToInsert.length > 0) { await db.insert(taskFieldValues).values(fieldValuesToInsert); } // Batch insert all audit logs if (auditLogsToInsert.length > 0) { await db.insert(taskAuditLog).values(auditLogsToInsert as any); } // Sync assignees for (const { taskId, userId } of assigneesToAdd) { await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {}); } // Persist idempotency key atomically with the created tasks if (idempotencyKey && idempotencyKey.length <= 255) { await db.insert(idempotencyKeys).values({ key: idempotencyKey, organizationId: req.organizationId!, userId: req.user!.id, endpoint: endpointPath, payloadHash, responseJson: { created: createdIds.length, taskIds: createdIds }, expiresAt: sql`NOW() + INTERVAL '1 hour'`, }); } return { cached: false as const, created: createdIds.length, taskIds: createdIds, autoTransitionTasks }; }); // ── Phase 3: post-commit side effects (must happen AFTER commit so other requests see data) ── if (!txResult.cached) { for (const { taskId, assignedTo } of txResult.autoTransitionTasks) { const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null, preloaded: { statuses: formStatusesList, formFields, users, roles, transitions, } }); let finalTask = await storage.getTask(taskId, req.organizationId!); if (autoResult.changed && finalTask) { finalTask = await storage.getTask(taskId, req.organizationId!) ?? finalTask; } if (!finalTask) continue; eventBus.publishEvent({ type: 'task_updated', organizationId: req.organizationId!, data: { taskId, formId, task: finalTask } }); if (assignedTo && assignedTo !== req.user!.id) { notifyTaskAssigned(finalTask, assignedTo, req.user!.id, req.organizationId!) .catch((err) => console.error('[BatchCreate] notifyTaskAssigned error:', err)); } indexTaskAsync(taskId, req.organizationId!).catch(() => {}); } } tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`); res.status(txResult.cached ? 200 : 201).json({ success: true, message: txResult.cached ? `Задачи уже созданы (${txResult.created})` : `Создано ${txResult.created} задач`, created: txResult.created, taskIds: txResult.taskIds, cached: txResult.cached, }); } catch (error) { console.error('Batch create tasks error:', error); const message = error instanceof Error ? error.message : 'Ошибка при пакетном создании задач'; res.status(500).json({ success: false, error: message }); } }); }