Инфраструктура автоматизаций: - триггер task.status_changed оживлён: вызов из executeTaskTransition (fire-and-forget) + фильтр triggerConfig.statusId - ctx.users.setFieldValue(userId, fieldCodeOrId, value): запись user_profile_field_values с аудитом; для history-number — append в user_field_history (миграция 0078) - документация sandbox обновлена (Automations.tsx, mcp.ts, AGENTS.md) по критическому правилу 2 Мультивыбор пользователей и ролей в user-поле: - чекбокс «Множественный выбор (пользователи и роли)» в настройках поля (form_fields.allow_multiple) - значение — массив ['user:ID','role:ID'], обратная совместимость с одиночными/legacy значениями - UserRoleMultiSelect в карточке задачи, реестре и диалоге создания (роли добавлены и в одиночный сценарий создания) - синк в task_assignees итерирует массив; role:ID раскрывается в членов роли; PATCH single — diff старого/нового значений - отображение массивов: реестр, title-шаблоны, условия полей, MCP update_task_fields - список орг-ролей (/api/roles) загружается всем пользователям (сервер и так был открыт) — роли доступны не только админам График history-number в профиле: - GET /api/users/:id/field-history/:fieldId?period=week|month|quarter|year (агрегация AVG по бакетам) - диалог с LineChart (recharts) и переключателем периодов по клику на иконку у поля
554 lines
25 KiB
TypeScript
554 lines
25 KiB
TypeScript
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 { indexTaskAsync } from "../routes/task-helpers";
|
||
import { fieldConditionsMatch, resolveFieldDisplayText, isFieldValueEmpty, SYSTEM_TASK_FIELDS, buildSystemFieldValuesForConditions } from "../utils/field-conditions";
|
||
import { validateRequiredFieldsDetailed } from "../utils/validate-required-fields";
|
||
import { ensureReviewAssignees, cleanupStaleDelegates } from "./delegation.service";
|
||
import { runAutomationsByTrigger } from "../routes/automation.routes";
|
||
|
||
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<TransitionResult> {
|
||
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<number>();
|
||
for (const f of formFields) {
|
||
if ((f as any).type === 'table') {
|
||
const tid = ((f as any).options as Record<string, number> | undefined)?.tableId;
|
||
if (tid) tableIds.add(tid);
|
||
}
|
||
}
|
||
const tableMap = new Map<number, { rows: Array<{ id: number; values: string[] }>; 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<string, unknown>;
|
||
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<string, string>).fieldCode);
|
||
if (!field) continue;
|
||
const fv = allFieldValues.find((fv: any) => fv.fieldId === field!.id);
|
||
const operator = (rule as Record<string, unknown>).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
|
||
|
||
// 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<string, unknown>;
|
||
|
||
// 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) {
|
||
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) {
|
||
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) {
|
||
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<string, string>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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<string, unknown>).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;
|
||
|
||
// «Согласующие» перехода (assigneeUserId / assigneeConditions) — это ТОЛЬКО
|
||
// авторизация (кто может выполнить переход). Они НЕ меняют ответственного задачи:
|
||
// ответственный всегда сохраняется, автоназначения при переходе нет.
|
||
const targetAssigneeId: number | null = existingTask.assignedTo ?? null;
|
||
|
||
// Update task status
|
||
const updateData: Record<string, unknown> = {
|
||
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);
|
||
});
|
||
}
|
||
|
||
// Замещение: вернуть отсутствующих исполнителей «на ознакомление»
|
||
// и снять заместителей с истёкшим замещением
|
||
await ensureReviewAssignees(taskId, organizationId);
|
||
await cleanupStaleDelegates(taskId, organizationId);
|
||
|
||
// 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);
|
||
});
|
||
}
|
||
|
||
// Доступ по переходу (grantedVia='transition', со statusId и roleId).
|
||
// ВНИМАНИЕ: кликнувшего НЕ добавляем в task_assignees — это список ответственных,
|
||
// а не участников; иначе любой выполнивший переход «становился ответственным».
|
||
if (user?.id) {
|
||
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(() => {});
|
||
|
||
// Автоматизации по триггеру task.status_changed — fire-and-forget,
|
||
// чтобы ошибки/таймауты скриптов не ломали сам переход
|
||
if (targetStatusId !== transition.fromStatusId) {
|
||
runAutomationsByTrigger(organizationId, 'task.status_changed', {
|
||
formId: existingTask.formId,
|
||
taskId,
|
||
oldStatusId: transition.fromStatusId,
|
||
newStatusId: targetStatusId,
|
||
task: updatedTask,
|
||
}).catch(err => console.error('[automation] task.status_changed error:', err));
|
||
}
|
||
|
||
return {
|
||
success: true,
|
||
message: 'Переход выполнен успешно',
|
||
task: updatedTask
|
||
};
|
||
} catch (error) {
|
||
console.error('Execute transition error:', error);
|
||
return { success: false, error: 'Ошибка при выполнении перехода', status: 500 };
|
||
}
|
||
}
|