fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
556
server/services/task-transition.service.ts
Normal file
556
server/services/task-transition.service.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
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<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
|
||||
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<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) {
|
||||
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<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;
|
||||
|
||||
// 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<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);
|
||||
});
|
||||
}
|
||||
|
||||
// Уведомление новому ответственному, если он изменился
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user