import { storage } from "../storage"; import { webhookService } from "./webhook.service"; import { eventBus, resolveTaskFieldTitles } from "../routes/shared"; import { pushTaskUpdated } from "../utils/pushTaskUpdated"; import { tasksMinimalCache } from "../utils/cache"; import { notifyTaskAssigned } from "../utils/notifyAssignee"; import { indexTaskAsync } from "../routes/task-helpers"; import { fieldConditionsMatch, resolveFieldDisplayText, isFieldValueEmpty, SYSTEM_TASK_FIELDS, buildSystemFieldValuesForConditions } from "../utils/field-conditions"; import { validateRequiredFieldsDetailed } from "../utils/validate-required-fields"; export interface ExecuteTaskTransitionOptions { taskId: number; transitionId: number; action?: 'approve' | 'reject'; user: any; organizationId: number; offlineEnqueuedAt?: Date | null; } export type TransitionResult = | { success: true; task: any; message: string } | { success: false; error: string; status: number; fields?: Array<{ fieldId: number; message: string }> }; export async function executeTaskTransition(options: ExecuteTaskTransitionOptions): Promise { const { taskId, transitionId, action, user, organizationId, offlineEnqueuedAt } = options; try { if (!transitionId) { return { success: false, error: 'Неверные параметры', status: 400 }; } // Verify task exists and belongs to organization const existingTask = await storage.getTask(taskId, organizationId); if (!existingTask) { return { success: false, error: 'Задача не найдена', status: 404 }; } // Get transition details const transitions = await storage.getStatusTransitions(existingTask.formId, organizationId); const transition = transitions.find(t => t.id === transitionId); if (!transition) { return { success: false, error: 'Переход не найден', status: 404 }; } // Verify transition is from current status if (transition.fromStatusId !== existingTask.currentStatusId) { return { success: false, error: 'Переход недоступен для текущего статуса', status: 400 }; } // Determine target status and action text based on: // 1. Explicit action parameter from request (new approach) // 2. transition.actionType from config (legacy approach for backward compatibility) let targetStatusId: number; let actionText: string; // If action is explicitly provided, use it if (action === 'reject') { // New approach: explicit reject action with rejectToStatusId if (!transition.rejectToStatusId) { return { success: false, error: 'Действие отклонения не настроено для этого перехода', status: 400 }; } targetStatusId = transition.rejectToStatusId; actionText = transition.rejectButtonText || 'Отклонено'; } else if (action === 'approve') { // Explicit approve action targetStatusId = transition.toStatusId; if (transition.actionType === 'custom' && transition.actionName) { actionText = transition.actionName; } else { actionText = transition.approveButtonText || 'Одобрено'; } } else { // No action provided - use legacy actionType for backward compatibility if (transition.actionType === 'reject') { // Legacy reject transition (separate transition with actionType='reject') targetStatusId = transition.toStatusId; actionText = transition.rejectButtonText || 'Отклонено'; } else if (transition.actionType === 'custom') { // Custom transition targetStatusId = transition.toStatusId; actionText = transition.actionName || transition.approveButtonText || 'Выполнено'; } else { // Default: approve targetStatusId = transition.toStatusId; actionText = transition.approveButtonText || 'Одобрено'; } } // Build context for resolving display text of fields in conditions const [orgUsers, orgRoles, fieldValues, formFields, statuses] = await Promise.all([ storage.getUsersByOrganization(organizationId), storage.getRoles(organizationId), storage.getTaskFieldValues(taskId, organizationId), storage.getFormFields(existingTask.formId, organizationId), storage.getFormStatuses(existingTask.formId, organizationId), ]); // Merge system fields (_assignee, _status, _due_date, title, etc.) so rules can reference them const currentStatusName = statuses.find((s: any) => s.id === existingTask.currentStatusId)?.name; const systemFieldValues = buildSystemFieldValuesForConditions(existingTask, { statusName: currentStatusName }); const allFields = [...formFields, ...SYSTEM_TASK_FIELDS]; const allFieldValues = [...fieldValues, ...systemFieldValues]; const usersWithFullName = orgUsers.map((u: any) => ({ ...u, fullName: `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() || u.email, })); const valueByFieldId = new Map(fieldValues.map((v: any) => [v.fieldId, v.value])); const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, organizationId); const tableIds = new Set(); for (const f of formFields) { if ((f as any).type === 'table') { const tid = ((f as any).options as Record | undefined)?.tableId; if (tid) tableIds.add(tid); } } const tableMap = new Map; columns: Array<{ name: string }> }>(); for (const tableId of tableIds) { const fullTable = await (storage as any).getDataTableFull(tableId, organizationId); if (fullTable) { tableMap.set(tableId, { rows: fullTable.rows as Array<{ id: number; values: string[] }>, columns: fullTable.columns as Array<{ name: string }>, }); } } const conditionContext = { users: usersWithFullName, roles: orgRoles, taskTitleMap, tableMap, }; // Apply conditional routing: override targetStatusId based on task field values const conditionsData = transition.conditions as Record; if (conditionsData) { const isRejectAction = action === 'reject' || (!action && transition.actionType === 'reject'); const routingRules = isRejectAction ? conditionsData.rejectRoutingRules : conditionsData.routingRules; if (routingRules && Array.isArray(routingRules) && routingRules.length > 0) { let routingResolved = false; for (const rule of routingRules) { if (!rule.fieldCode || !rule.toStatusId) continue; const field = allFields.find(f => f.code === (rule as Record).fieldCode); if (!field) continue; const fv = allFieldValues.find((fv: any) => fv.fieldId === field!.id); const operator = (rule as Record).operator || 'contains'; if (operator === 'empty') { if (isFieldValueEmpty(fv?.value, field.type)) { targetStatusId = rule.toStatusId; routingResolved = true; break; } } else if (operator === 'filled') { if (!isFieldValueEmpty(fv?.value, field.type)) { targetStatusId = rule.toStatusId; routingResolved = true; break; } } else { const displayText = resolveFieldDisplayText(fv?.value, field as any, conditionContext); if (displayText.includes(String(rule.value ?? ''))) { targetStatusId = rule.toStatusId; routingResolved = true; break; } } } if (!routingResolved) { const defaultId = isRejectAction ? conditionsData.defaultRejectToStatusId : conditionsData.defaultToStatusId; if (defaultId) targetStatusId = defaultId as number; } } } // Check if user is authorized to perform transition let isAuthorized = false; let resolvedRoleId: number | null = null; // track role to assign after transition let resolvedAssigneeId: number | null = null; // old-format resolved user let resolvedUserId: number | null = null; // new-format resolved user // Check direct assigneeUserId (Type A simple — backward compat) if (!isAuthorized && transition.assigneeUserId !== null && transition.assigneeUserId !== undefined) { if (transition.assigneeUserId === user?.id) { isAuthorized = true; } } // Check assignee conditions if present if (!isAuthorized && transition.assigneeConditions) { const conditions = transition.assigneeConditions as Record; // NEW format: { assignees: [{ type: 'user'|'role'|'field_user'|'table_column_user', id?, fieldCode?, columnIndex?, rules: [{ fieldCode, value }] }] } if (conditions.assignees && Array.isArray(conditions.assignees)) { for (const assignee of conditions.assignees) { if ((assignee.type === 'user' || assignee.type === 'role') && !assignee.id) continue; if ((assignee.type === 'field_user' || assignee.type === 'table_column_user') && !assignee.fieldCode) continue; const rulesMatch = fieldConditionsMatch(assignee.rules || [], allFieldValues, allFields, conditionContext); if (!rulesMatch) continue; if (assignee.type === 'user' && assignee.id === user?.id) { resolvedUserId = assignee.id; isAuthorized = true; break; } if (assignee.type === 'role') { const members = await storage.getRoleMembersByRole(assignee.id); if (members.some((m: any) => m.userId === user?.id)) { resolvedRoleId = assignee.id; isAuthorized = true; break; } } if (assignee.type === 'field_user') { let userId: number | undefined; if (assignee.fieldCode === '_assignee') { userId = existingTask.assignedTo ?? undefined; } else if (assignee.fieldCode === '_author') { userId = existingTask.createdBy ?? undefined; } else { const field = allFields.find(f => f.code === assignee.fieldCode); const fieldValue = allFieldValues.find((fv: any) => fv.fieldId === field?.id); userId = fieldValue?.value ? parseInt(String(fieldValue.value)) : undefined; } if (userId && userId > 0 && userId === user?.id) { resolvedUserId = userId; isAuthorized = true; break; } } if (assignee.type === 'table_column_user') { const field = allFields.find(f => f.code === assignee.fieldCode); const fieldValue = allFieldValues.find((fv: any) => fv.fieldId === field?.id); const rowId = fieldValue?.value ? parseInt(String(fieldValue.value)) : NaN; if (!isNaN(rowId) && rowId > 0) { const tableId = (field as any)?.options?.tableId as number; if (tableId) { const row = await storage.getDataTableRow(rowId, tableId, organizationId); if (row) { const userId = parseInt(String((row.values as unknown[])[assignee.columnIndex as number] ?? '')); if (!isNaN(userId) && userId > 0 && userId === user?.id) { resolvedUserId = userId; isAuthorized = true; break; } } } } } } } else if (conditions.rules && Array.isArray(conditions.rules)) { // OLD format: { rules: [...], fallbackAssigneeUserId? } // Rule types: // A) { fieldCode, value, assigneeUserId } // Direct: if field = value → assigneeUserId // B) { fieldCode, value, tableId, tableKeyColumnIndex, tableUserColumnIndex } // Lookup: if field = value → find справочник row where col[keyIdx]=value → user = col[userIdx] // C) { fieldCode, tableUserColumnIndex } (no tableId, field.type = 'table') // Table-field: task's table field stores a row ID → user = that row's col[userIdx] // D) { fieldCode, value, assigneeSource: 'role', roleId } // Role: if field contains value → check if current user is member of roleId for (const rule of conditions.rules) { const field = allFields.find(f => f.code === (rule as Record).fieldCode); if (!field) continue; const fieldValue = allFieldValues.find((fv: any) => fv.fieldId === field!.id); if (rule.assigneeSource === 'role' && rule.roleId !== undefined) { // Type D: role-based assignee const operator = (rule as Record).operator || 'contains'; let fieldMatches = false; if (operator === 'empty') { fieldMatches = isFieldValueEmpty(fieldValue?.value, field.type); } else if (operator === 'filled') { fieldMatches = !isFieldValueEmpty(fieldValue?.value, field.type); } else { const ruleVal = String(rule.value ?? ''); const displayText = resolveFieldDisplayText(fieldValue?.value, field as any, conditionContext); fieldMatches = !ruleVal || displayText.toLowerCase().includes(ruleVal.toLowerCase()); } if (fieldMatches) { const members = await storage.getRoleMembersByRole(rule.roleId); if (members.some(m => m.userId === user?.id)) { resolvedRoleId = rule.roleId; isAuthorized = true; break; } } } else if (rule.tableId !== undefined && rule.tableUserColumnIndex !== undefined) { // Type B: справочник lookup from any field const taskVal = String(fieldValue?.value ?? ''); if (!taskVal) continue; // empty field — no match possible const rows = await storage.getDataTableRows(rule.tableId, organizationId); const keyIdx = rule.tableKeyColumnIndex as number; const userIdx = rule.tableUserColumnIndex as number; // If rule.value is set, only apply this rule when the task field equals that value if (rule.value !== undefined && taskVal !== String(rule.value)) continue; const matchRow = rows.find(r => String((r.values as unknown[])[keyIdx] ?? '') === taskVal); if (!matchRow) continue; const userId = parseInt(String((matchRow.values as unknown[])[userIdx] ?? '')); if (!isNaN(userId)) { resolvedAssigneeId = userId; break; } } else if ((rule as Record).tableUserColumnIndex !== undefined && (field as any)?.options?.tableId) { // Type C: task field is table-type, value = selected row ID const tableId = (field as any).options.tableId as number; const rowId = fieldValue?.value ? parseInt(String(fieldValue.value)) : NaN; if (isNaN(rowId)) continue; const row = await storage.getDataTableRow(rowId, tableId, organizationId); if (!row) continue; const userId = parseInt(String((row.values as unknown[])[rule.tableUserColumnIndex as number] ?? '')); if (!isNaN(userId)) { resolvedAssigneeId = userId; break; } } else if (rule.assigneeUserId !== undefined) { // Type A: direct field → user (contains match) const operator = (rule as Record).operator || 'contains'; if (operator === 'empty') { if (isFieldValueEmpty(fieldValue?.value, field.type)) { resolvedAssigneeId = rule.assigneeUserId; break; } } else if (operator === 'filled') { if (!isFieldValueEmpty(fieldValue?.value, field.type)) { resolvedAssigneeId = rule.assigneeUserId; break; } } else { const ruleVal = String(rule.value ?? ''); const displayText = resolveFieldDisplayText(fieldValue?.value, field as any, conditionContext); if (ruleVal && displayText.toLowerCase().includes(ruleVal.toLowerCase())) { resolvedAssigneeId = rule.assigneeUserId; break; } } } } if (resolvedAssigneeId === null && conditions.fallbackAssigneeUserId) { resolvedAssigneeId = conditions.fallbackAssigneeUserId as number; } if (resolvedAssigneeId !== null && resolvedAssigneeId === user?.id) { isAuthorized = true; } } else if (conditions.field && conditions.value && conditions.assigneeId) { // Backward-compat: old format { field, value, assigneeId } const operator = (conditions as Record).operator || 'contains'; const field = allFields.find(f => f.code === String(conditions.field)); if (field) { const fieldValue = allFieldValues.find((fv: any) => fv.fieldId === field!.id); let matches = false; if (operator === 'empty') { matches = isFieldValueEmpty(fieldValue?.value, field.type); } else if (operator === 'filled') { matches = !isFieldValueEmpty(fieldValue?.value, field.type); } else { const condVal = String(conditions.value ?? ''); const displayText = resolveFieldDisplayText(fieldValue?.value, field as any, conditionContext); matches = !!condVal && displayText.toLowerCase().includes(condVal.toLowerCase()); } if (matches && conditions.assigneeId === user?.id) { resolvedAssigneeId = conditions.assigneeId as number; isAuthorized = true; } } } } if (!isAuthorized) { return { success: false, error: 'У вас нет прав для выполнения этого действия', status: 403 }; } // Get status names for the message (already fetched earlier) const fromStatus = statuses.find(s => s.id === transition.fromStatusId); const toStatus = statuses.find(s => s.id === targetStatusId); // Validate required fields for current status before leaving it. // The rule "required in status X" must be satisfied when the task tries to exit X, // not when it enters the target status. const requiredFieldErrors = await validateRequiredFieldsDetailed({ task: existingTask, formFields, existingFieldValues: fieldValues, organizationId, storage, statuses, isCreation: false, }); if (requiredFieldErrors.length > 0) { return { success: false, error: `Перед выходом из статуса «${fromStatus?.name || transition.fromStatusId}» заполните обязательные поля:\n${requiredFieldErrors.map((e) => e.message).join('\n')}`, status: 400, fields: requiredFieldErrors, }; } // Check if target status is final const isFinalStatus = toStatus?.isFinal || false; // Determine assignee based on transition rules (priority: direct > old format > new format > preserve current) let targetAssigneeId: number | null = null; if (transition.assigneeUserId !== null && transition.assigneeUserId !== undefined) { targetAssigneeId = transition.assigneeUserId; } else if (resolvedAssigneeId !== null) { targetAssigneeId = resolvedAssigneeId; } else if (resolvedUserId !== null) { targetAssigneeId = resolvedUserId; } else { // If the transition does not explicitly specify an assignee, keep the existing one // instead of defaulting to the user who clicked the button. targetAssigneeId = existingTask.assignedTo ?? null; } // Update task status const updateData: Record = { currentStatusId: targetStatusId, assignedTo: targetAssigneeId, }; // If transitioning to final status, mark as completed; otherwise reset if (isFinalStatus) { updateData.isCompleted = true; updateData.completedAt = new Date(); } else { updateData.isCompleted = false; updateData.completedAt = null; } const updatedTask = await storage.updateTask(taskId, organizationId, updateData); tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`); // Sync the primary assignee into task_assignees so TaskDetail can display it if (targetAssigneeId) { storage.addTaskAssignee(taskId, targetAssigneeId, organizationId).catch(err => { console.error('addTaskAssignee (transition target) error:', err); }); } // Уведомление новому ответственному, если он изменился if (targetAssigneeId && targetAssigneeId !== existingTask.assignedTo) { notifyTaskAssigned(updatedTask, targetAssigneeId, user?.id ?? null, organizationId) .catch((err) => console.error('[Transition] notifyTaskAssigned error:', err)); } // If a role was resolved as assignee — assign it to the task if (resolvedRoleId !== null) { await storage.addTaskRole(taskId, resolvedRoleId, organizationId).catch(err => { console.error('addTaskRole error:', err); }); } // Add current user as task assignee (upsert — no duplicates) if (user?.id) { storage.addTaskAssignee(taskId, user.id, organizationId).catch(err => { console.error('addTaskAssignee (transition) error:', err); }); // Record transition access (grantedVia='transition', with statusId and roleId) storage.recordTransitionAccess(taskId, user.id, organizationId, resolvedRoleId, targetStatusId).catch(err => { console.error('recordTransitionAccess error:', err); }); } // Audit log: status changed const statusChangerName = user ? (`${user.firstName || ''} ${user.middleName || ''} ${user.lastName || ''}`.trim() || user.email) : 'API'; storage.addTaskAuditLog({ taskId, organizationId, action: 'status.changed', fieldName: 'Статус', oldValue: fromStatus?.name ?? String(transition.fromStatusId), newValue: toStatus?.name ?? String(targetStatusId), changedBy: user?.id ?? null, changedByName: statusChangerName, metadata: { transitionId: transition.id, actionText }, ...(offlineEnqueuedAt ? { createdAt: offlineEnqueuedAt } : {}), }).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); }); // Audit log: transition approved (for access tab and history display) if (user?.id) { let roleName: string | null = null; if (resolvedRoleId !== null) { try { const role = await storage.getRole(resolvedRoleId, organizationId); roleName = role?.name ?? null; } catch { /* ignore */ } } storage.addTaskAuditLog({ taskId, organizationId, action: 'transition_approved', fieldName: null, oldValue: null, newValue: null, changedBy: user.id, changedByName: statusChangerName, metadata: { transitionId: transition.id, actionText, statusId: targetStatusId, statusName: toStatus?.name ?? String(targetStatusId), roleId: resolvedRoleId, roleName, }, ...(offlineEnqueuedAt ? { createdAt: offlineEnqueuedAt } : {}), }).catch((auditErr: unknown) => { console.error('Audit log (transition_approved) error:', auditErr); }); } // Publish task update event via SSE eventBus.publishEvent({ type: 'task_updated', data: { taskId: taskId, formId: existingTask.formId, task: updatedTask }, organizationId }); // Web Push: notify all offline task participants (assignee, creator, access list) pushTaskUpdated({ taskId, organizationId, formId: existingTask.formId, taskTitle: updatedTask.title, actorId: user!.id, }).catch(() => {}); // Dispatch webhook event to subscribed bots webhookService.dispatchStatusChange( organizationId, taskId, existingTask.formId, transition.fromStatusId, targetStatusId, transition.id, user!.id ).catch(err => console.error('Webhook dispatch error:', err)); indexTaskAsync(taskId, organizationId).catch(() => {}); return { success: true, message: 'Переход выполнен успешно', task: updatedTask }; } catch (error) { console.error('Execute transition error:', error); return { success: false, error: 'Ошибка при выполнении перехода', status: 500 }; } }