feat(documents): папки шаблонов, маппинги и поле document_template; fix(batch): атомарное создание задач, idempotency и массовое удаление в реестре
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import { Router } from "express";
|
||||
import crypto from "crypto";
|
||||
import { storage } from "../storage";
|
||||
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { validateRequest } from "../middleware/validation.middleware";
|
||||
import { insertTaskSchema } from "@shared/schema";
|
||||
import { insertTaskSchema, idempotencyKeys, taskFieldValues, taskAuditLog } from "@shared/schema";
|
||||
import { notificationService } from "../services/notification.service";
|
||||
import { buildSystemFieldValues, buildTableRowMap, commitPendingUploads, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared";
|
||||
import { tasksMinimalCache } from "../utils/cache";
|
||||
@@ -15,6 +16,8 @@ import { validateRequiredFields } from "../utils/validate-required-fields";
|
||||
import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service";
|
||||
import { runAutomationsByTrigger, type AutomationRunResult } from "./automation.routes";
|
||||
import { normalizeFieldValueForStorage } from "../utils/normalize-field-value";
|
||||
import { db, withTenant } from "../db";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
|
||||
function normalizeCustomFields(
|
||||
customFields: Record<string, unknown> | undefined,
|
||||
@@ -706,7 +709,7 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
return res.status(404).json({ success: false, error: 'Форма не найдена' });
|
||||
}
|
||||
|
||||
const { tasks: taskList } = req.body;
|
||||
const { tasks: taskList, idempotencyKey } = req.body as { tasks?: unknown[]; idempotencyKey?: string };
|
||||
if (!Array.isArray(taskList) || taskList.length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'Необходимо передать массив задач' });
|
||||
}
|
||||
@@ -735,12 +738,18 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
// Pre-build table row map once (used by title template and auto-transitions)
|
||||
const tableRowMap = await buildTableRowMap(formFields, req.organizationId!);
|
||||
|
||||
const createdIds: number[] = [];
|
||||
interface PreparedTask {
|
||||
taskData: any;
|
||||
normalizedCustomFields: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// ── Phase 1: validate and run pre-create automations for ALL tasks before any DB write ──
|
||||
const preparedTasks: PreparedTask[] = [];
|
||||
for (let i = 0; i < taskList.length; i++) {
|
||||
const taskBody = taskList[i];
|
||||
const taskBody = taskList[i] as any;
|
||||
const { customFields, dueDate, ...baseTaskData } = taskBody;
|
||||
|
||||
let parsedDueDate = null;
|
||||
let parsedDueDate: Date | null = null;
|
||||
if (dueDate && dueDate !== '') {
|
||||
if (dueDate instanceof Date) {
|
||||
parsedDueDate = dueDate;
|
||||
@@ -758,6 +767,49 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedCustomFields = normalizeCustomFields(customFields, formFields);
|
||||
|
||||
const validationErrors = await validateRequiredFields({
|
||||
task: { currentStatusId: initialStatus.id, formId },
|
||||
formFields,
|
||||
customFields: normalizedCustomFields,
|
||||
organizationId: req.organizationId!,
|
||||
storage,
|
||||
isCreation: true,
|
||||
statuses: formStatusesList,
|
||||
users,
|
||||
roles,
|
||||
});
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Ошибка в элементе ${i + 1}: ${validationErrors.join('; ')}`,
|
||||
});
|
||||
}
|
||||
|
||||
const preCreateResults = await runAutomationsByTrigger(req.organizationId!, 'task.before_create', {
|
||||
formId,
|
||||
task: {
|
||||
formId,
|
||||
title: baseTaskData.title,
|
||||
currentStatusId: initialStatus.id,
|
||||
dueDate: parsedDueDate,
|
||||
customFields: normalizedCustomFields,
|
||||
},
|
||||
});
|
||||
for (const run of preCreateResults) {
|
||||
const result = run.result as { allow?: boolean; error?: string; activeRentalTaskId?: number; assignedToName?: string | null } | undefined;
|
||||
if (result && result.allow === false) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Элемент ${i + 1}: ${result.error || 'Создание задачи запрещено автоматизацией'}`,
|
||||
automationId: run.automationId,
|
||||
activeRentalTaskId: result.activeRentalTaskId,
|
||||
assignedToName: result.assignedToName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const taskData = {
|
||||
...baseTaskData,
|
||||
formId,
|
||||
@@ -767,168 +819,221 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
|
||||
currentStatusId: initialStatus.id,
|
||||
};
|
||||
|
||||
// Validate conditional required fields for each batch item
|
||||
const batchValidationErrors = await validateRequiredFields({
|
||||
task: { currentStatusId: initialStatus.id, formId },
|
||||
formFields,
|
||||
customFields,
|
||||
organizationId: req.organizationId!,
|
||||
storage,
|
||||
isCreation: true,
|
||||
statuses: formStatusesList,
|
||||
users,
|
||||
roles,
|
||||
});
|
||||
if (batchValidationErrors.length > 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Ошибка в элементе ${i + 1}: ${batchValidationErrors.join('; ')}`,
|
||||
});
|
||||
}
|
||||
preparedTasks.push({ taskData, normalizedCustomFields });
|
||||
}
|
||||
|
||||
let task = await storage.createTask(taskData);
|
||||
const payloadHash = crypto.createHash('sha256').update(JSON.stringify(taskList)).digest('hex');
|
||||
const endpointPath = `/api/forms/${formId}/tasks/batch`;
|
||||
const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
|
||||
|
||||
// Sync the primary assignee into task_assignees so TaskDetail can display it
|
||||
if (task.assignedTo) {
|
||||
await storage.addTaskAssignee(task.id, task.assignedTo, req.organizationId!).catch(() => {});
|
||||
}
|
||||
|
||||
if (customFields && typeof customFields === 'object') {
|
||||
const normalizedCustomFields = normalizeCustomFields(customFields, formFields);
|
||||
for (const [fieldKey, value] of Object.entries(normalizedCustomFields)) {
|
||||
const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/);
|
||||
if (fieldIdMatch && value !== undefined && value !== '') {
|
||||
const fieldId = parseInt(fieldIdMatch[1]);
|
||||
const field = fieldMap.get(fieldId);
|
||||
if (!field) continue;
|
||||
|
||||
await storage.createTaskFieldValue({
|
||||
taskId: task.id,
|
||||
fieldId,
|
||||
formId,
|
||||
value: normalizeFieldValueForStorage(value, field.type)
|
||||
});
|
||||
// ── Phase 2: atomic creation inside one tenant transaction ──
|
||||
const txResult = await withTenant(req.organizationId!, async () => {
|
||||
// Idempotency check (inside transaction so it shares RLS context)
|
||||
if (idempotencyKey && idempotencyKey.length <= 255) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(idempotencyKeys)
|
||||
.where(
|
||||
and(
|
||||
eq(idempotencyKeys.organizationId, req.organizationId!),
|
||||
eq(idempotencyKeys.key, idempotencyKey),
|
||||
sql`${idempotencyKeys.expiresAt} > NOW()`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (existing.length > 0) {
|
||||
const stored = existing[0];
|
||||
if (stored.payloadHash !== payloadHash) {
|
||||
throw new Error('Idempotency key used with different payload');
|
||||
}
|
||||
const resp = stored.responseJson as { created?: number; taskIds?: number[] };
|
||||
return { cached: true as const, created: resp.created ?? 0, taskIds: resp.taskIds ?? [] };
|
||||
}
|
||||
}
|
||||
|
||||
if (existingForm.titleTemplate) {
|
||||
const savedValues = await storage.getTaskFieldValues(task.id, req.organizationId!);
|
||||
const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value]));
|
||||
const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: formStatusesList, usersMap });
|
||||
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!);
|
||||
const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_: string, code: string) => {
|
||||
if (sysValues.has(code)) return sysValues.get(code)!;
|
||||
const field = fieldCodeMap.get(code);
|
||||
if (!field) return '';
|
||||
const val = valueByFieldId.get(field.id);
|
||||
if (val === null || val === undefined) return '';
|
||||
if (val === '__auto_prolongation__' && (field.type === 'date' || field.type === 'datetime')) {
|
||||
return field.autoProlongationLabel || 'Автопролонгация';
|
||||
}
|
||||
return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap, tableRowMap }, field.options);
|
||||
});
|
||||
task = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle });
|
||||
}
|
||||
const createdIds: number[] = [];
|
||||
const fieldValuesToInsert: any[] = [];
|
||||
const auditLogsToInsert: any[] = [];
|
||||
const assigneesToAdd: Array<{ taskId: number; userId: number }> = [];
|
||||
const autoTransitionTasks: Array<{ taskId: number; assignedTo?: number | null }> = [];
|
||||
|
||||
// Auto-assign task based on transition rules from current status
|
||||
if (!task.assignedTo && task.currentStatusId) {
|
||||
const outgoingTransitions = transitions.filter(t => t.fromStatusId === task.currentStatusId);
|
||||
let targetUserId: number | null = null;
|
||||
for (let i = 0; i < preparedTasks.length; i++) {
|
||||
const { taskData, normalizedCustomFields } = preparedTasks[i];
|
||||
|
||||
for (const transition of outgoingTransitions) {
|
||||
if (transition.assigneeUserId) {
|
||||
targetUserId = transition.assigneeUserId;
|
||||
break;
|
||||
let task = await storage.createTask(taskData);
|
||||
|
||||
if (task.assignedTo) {
|
||||
assigneesToAdd.push({ taskId: task.id, userId: task.assignedTo });
|
||||
}
|
||||
|
||||
// Collect field values for batch insert
|
||||
if (normalizedCustomFields && typeof normalizedCustomFields === 'object') {
|
||||
for (const [fieldKey, value] of Object.entries(normalizedCustomFields)) {
|
||||
const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/);
|
||||
if (fieldIdMatch && value !== undefined && value !== '') {
|
||||
const fieldId = parseInt(fieldIdMatch[1]);
|
||||
const field = fieldMap.get(fieldId);
|
||||
if (!field) continue;
|
||||
fieldValuesToInsert.push({
|
||||
taskId: task.id,
|
||||
fieldId,
|
||||
formId,
|
||||
value: normalizeFieldValueForStorage(value, field.type),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (transition.assigneeConditions) {
|
||||
const conditions = transition.assigneeConditions as Record<string, unknown>;
|
||||
if (conditions.assignees && Array.isArray(conditions.assignees)) {
|
||||
const firstUser = conditions.assignees.find((a: any) => a.type === 'user');
|
||||
if (firstUser?.id) {
|
||||
targetUserId = firstUser.id;
|
||||
}
|
||||
|
||||
if (existingForm.titleTemplate) {
|
||||
const savedValues = await storage.getTaskFieldValues(task.id, req.organizationId!);
|
||||
const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value]));
|
||||
const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: formStatusesList, usersMap });
|
||||
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!);
|
||||
const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_: string, code: string) => {
|
||||
if (sysValues.has(code)) return sysValues.get(code)!;
|
||||
const field = fieldCodeMap.get(code);
|
||||
if (!field) return '';
|
||||
const val = valueByFieldId.get(field.id);
|
||||
if (val === null || val === undefined) return '';
|
||||
if (val === '__auto_prolongation__' && (field.type === 'date' || field.type === 'datetime')) {
|
||||
return field.autoProlongationLabel || 'Автопролонгация';
|
||||
}
|
||||
return formatFieldValueForTitle(val, field.type, { usersMap, rolesMap, taskTitleMap, tableRowMap }, field.options);
|
||||
});
|
||||
task = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle });
|
||||
}
|
||||
|
||||
// Auto-assign task based on transition rules from current status
|
||||
if (!task.assignedTo && task.currentStatusId) {
|
||||
const outgoingTransitions = transitions.filter(t => t.fromStatusId === task.currentStatusId);
|
||||
let targetUserId: number | null = null;
|
||||
|
||||
for (const transition of outgoingTransitions) {
|
||||
if (transition.assigneeUserId) {
|
||||
targetUserId = transition.assigneeUserId;
|
||||
break;
|
||||
}
|
||||
if (transition.assigneeConditions) {
|
||||
const conditions = transition.assigneeConditions as Record<string, unknown>;
|
||||
if (conditions.assignees && Array.isArray(conditions.assignees)) {
|
||||
const firstUser = conditions.assignees.find((a: any) => a.type === 'user');
|
||||
if (firstUser?.id) {
|
||||
targetUserId = firstUser.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (conditions.fallbackAssigneeUserId) {
|
||||
targetUserId = conditions.fallbackAssigneeUserId as number;
|
||||
break;
|
||||
}
|
||||
if (conditions.assigneeId) {
|
||||
targetUserId = conditions.assigneeId as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (conditions.fallbackAssigneeUserId) {
|
||||
targetUserId = conditions.fallbackAssigneeUserId as number;
|
||||
break;
|
||||
}
|
||||
if (conditions.assigneeId) {
|
||||
targetUserId = conditions.assigneeId as number;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUserId) {
|
||||
await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId });
|
||||
assigneesToAdd.push({ taskId: task.id, userId: targetUserId });
|
||||
const refreshed = await storage.getTask(task.id, req.organizationId!);
|
||||
if (refreshed) task = refreshed;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetUserId) {
|
||||
await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId });
|
||||
await storage.addTaskAssignee(task.id, targetUserId, req.organizationId!).catch(() => {});
|
||||
const refreshed = await storage.getTask(task.id, req.organizationId!);
|
||||
if (refreshed) task = refreshed;
|
||||
auditLogsToInsert.push({
|
||||
taskId: task.id,
|
||||
organizationId: req.organizationId!,
|
||||
action: 'task.created',
|
||||
changedBy: req.user?.id ?? null,
|
||||
changedByName: creatorName,
|
||||
metadata: { title: task.title },
|
||||
});
|
||||
|
||||
createdIds.push(task.id);
|
||||
autoTransitionTasks.push({ taskId: task.id, assignedTo: task.assignedTo });
|
||||
}
|
||||
|
||||
// Batch insert all custom field values
|
||||
if (fieldValuesToInsert.length > 0) {
|
||||
await db.insert(taskFieldValues).values(fieldValuesToInsert);
|
||||
}
|
||||
|
||||
// Batch insert all audit logs
|
||||
if (auditLogsToInsert.length > 0) {
|
||||
await db.insert(taskAuditLog).values(auditLogsToInsert as any);
|
||||
}
|
||||
|
||||
// Sync assignees
|
||||
for (const { taskId, userId } of assigneesToAdd) {
|
||||
await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {});
|
||||
}
|
||||
|
||||
// Persist idempotency key atomically with the created tasks
|
||||
if (idempotencyKey && idempotencyKey.length <= 255) {
|
||||
await db.insert(idempotencyKeys).values({
|
||||
key: idempotencyKey,
|
||||
organizationId: req.organizationId!,
|
||||
userId: req.user!.id,
|
||||
endpoint: endpointPath,
|
||||
payloadHash,
|
||||
responseJson: { created: createdIds.length, taskIds: createdIds },
|
||||
expiresAt: sql`NOW() + INTERVAL '1 hour'`,
|
||||
});
|
||||
}
|
||||
|
||||
return { cached: false as const, created: createdIds.length, taskIds: createdIds, autoTransitionTasks };
|
||||
});
|
||||
|
||||
// ── Phase 3: post-commit side effects (must happen AFTER commit so other requests see data) ──
|
||||
if (!txResult.cached) {
|
||||
for (const { taskId, assignedTo } of txResult.autoTransitionTasks) {
|
||||
const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, {
|
||||
triggeredBy: req.user?.id ?? null,
|
||||
preloaded: {
|
||||
statuses: formStatusesList,
|
||||
formFields,
|
||||
users,
|
||||
roles,
|
||||
transitions,
|
||||
}
|
||||
});
|
||||
let finalTask = await storage.getTask(taskId, req.organizationId!);
|
||||
if (autoResult.changed && finalTask) {
|
||||
finalTask = await storage.getTask(taskId, req.organizationId!) ?? finalTask;
|
||||
}
|
||||
}
|
||||
if (!finalTask) continue;
|
||||
|
||||
// Audit log
|
||||
const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
|
||||
storage.addTaskAuditLog({
|
||||
taskId: task.id,
|
||||
organizationId: req.organizationId!,
|
||||
action: 'task.created',
|
||||
changedBy: req.user?.id ?? null,
|
||||
changedByName: creatorName,
|
||||
metadata: { title: task.title },
|
||||
}).catch(() => {});
|
||||
eventBus.publishEvent({
|
||||
type: 'task_updated',
|
||||
organizationId: req.organizationId!,
|
||||
data: { taskId, formId, task: finalTask }
|
||||
});
|
||||
|
||||
// Notify assignee
|
||||
if (task.assignedTo && task.assignedTo !== req.user!.id) {
|
||||
notifyTaskAssigned(task, task.assignedTo, req.user!.id, req.organizationId!)
|
||||
.catch((err) => console.error('[BatchCreate] notifyTaskAssigned error:', err));
|
||||
}
|
||||
|
||||
// Evaluate auto-transitions based on entry conditions (with preloaded data)
|
||||
const autoResult = await evaluateAutoTransitions(task.id, req.organizationId!, {
|
||||
triggeredBy: req.user?.id ?? null,
|
||||
preloaded: {
|
||||
statuses: formStatusesList,
|
||||
formFields,
|
||||
users,
|
||||
roles,
|
||||
transitions,
|
||||
if (assignedTo && assignedTo !== req.user!.id) {
|
||||
notifyTaskAssigned(finalTask, assignedTo, req.user!.id, req.organizationId!)
|
||||
.catch((err) => console.error('[BatchCreate] notifyTaskAssigned error:', err));
|
||||
}
|
||||
});
|
||||
if (autoResult.changed) {
|
||||
const refreshed = await storage.getTask(task.id, req.organizationId!);
|
||||
if (refreshed) task = refreshed;
|
||||
}
|
||||
|
||||
// Notify other users in real-time
|
||||
eventBus.publishEvent({
|
||||
type: 'task_updated',
|
||||
organizationId: req.organizationId!,
|
||||
data: { taskId: task.id, formId: task.formId, task: task }
|
||||
});
|
||||
|
||||
indexTaskAsync(task.id, req.organizationId!).catch(() => {});
|
||||
createdIds.push(task.id);
|
||||
|
||||
// Yield to event loop to prevent blocking other requests
|
||||
if (i < taskList.length - 1) {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
indexTaskAsync(taskId, req.organizationId!).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
|
||||
|
||||
res.status(201).json({
|
||||
res.status(txResult.cached ? 200 : 201).json({
|
||||
success: true,
|
||||
message: `Создано ${createdIds.length} задач`,
|
||||
created: createdIds.length,
|
||||
taskIds: createdIds
|
||||
message: txResult.cached
|
||||
? `Задачи уже созданы (${txResult.created})`
|
||||
: `Создано ${txResult.created} задач`,
|
||||
created: txResult.created,
|
||||
taskIds: txResult.taskIds,
|
||||
cached: txResult.cached,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Batch create tasks error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при пакетном создании задач' });
|
||||
const message = error instanceof Error ? error.message : 'Ошибка при пакетном создании задач';
|
||||
res.status(500).json({ success: false, error: message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user