Files
iistwin/server/services/task-transition.service.ts
Ильяс Султанов 667bddeac9 feat: замещение пользователей (substitution) — заместители по умолчанию и по формам, контур ознакомления, inbox reasons
- таблица user_delegations (миграция 0067) + колонки delegated_from_user_id/pending_review в task_assignees
- delegation.service: резолвер с цепочкой и защитой от циклов, bulk-до-назначение чанками, чистка истёкших замещений
- центральный хук в addTaskAssignee: заместитель добавляется при любом назначении (ручном, автопереходе, user-поле, MCP)
- контур ознакомления: после перехода отсутствующий остаётся в исполнителях с pending_review, снимается кнопкой «Ознакомлен»
- inbox reasons delegation/delegation_review + фильтр «Замещение» на главной
- блок «Замещение» в профиле пользователя, бейдж «за <имя>» и баннер ознакомления в карточке задачи
2026-08-05 10:44:55 +03:00

541 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { 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";
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(() => {});
return {
success: true,
message: 'Переход выполнен успешно',
task: updatedTask
};
} catch (error) {
console.error('Execute transition error:', error);
return { success: false, error: 'Ошибка при выполнении перехода', status: 500 };
}
}