Files
iistwin/server/routes/task-crud-write.routes.ts
Ильяс Султанов 59079992dc Фикс предпросмотра файлов с абсолютными self-URL
- extractFileKey понимает абсолютные URL (по pathname) — превью работает для уже записанных файлов
- normalizeFileUrl: абсолютные self-URL нормализуются в относительные при записи file-полей (REST field-values, MCP update_task_fields/upload-инструменты, bot-api, create task)
2026-07-24 11:10:55 +03:00

1060 lines
48 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 { 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, 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";
import { indexTaskAsync, parseOfflineTimestamp } from "./task-helpers";
import { generateContractNumber } from "../services/contract-number.service";
import { evaluateAutoTransitions } from "../utils/auto-transitions";
import { notifyTaskAssigned } from "../utils/notifyAssignee";
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 { normalizeFileFieldUrls } from "../utils/upload";
import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor";
import { db, withTenant } from "../db";
import { eq, and, sql } from "drizzle-orm";
function normalizeCustomFields(
customFields: Record<string, unknown> | undefined,
formFields: Array<{ id: number; code: string }>
): Record<string, unknown> {
if (!customFields || typeof customFields !== 'object') return {};
const codeToId = new Map(formFields.map(f => [f.code, f.id]));
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(customFields)) {
if (key.startsWith('field_') && codeToId.has(key)) {
result[`customField_${codeToId.get(key)}`] = value;
} else {
result[key] = value;
}
}
return result;
}
export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("express").Router>): void {
router.post('/api/forms/:id/tasks',
validateRequest(insertTaskSchema.omit({ createdBy: true })),
async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
if (req.user) {
const canAccessForm = await storage.canUserAccessForm(req.user.id, formId, req.organizationId!, 'participate');
if (!canAccessForm) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' });
}
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
const apiKeyErrCreate = checkApiKeyWriteAccess(req, formId);
if (apiKeyErrCreate) {
return res.status(403).json({ success: false, error: apiKeyErrCreate });
}
const { customFields, dueDate, ...baseTaskData } = req.body;
let parsedDueDate = null;
if (dueDate && dueDate !== '') {
if (dueDate instanceof Date) {
parsedDueDate = dueDate;
} else if (typeof dueDate === 'string') {
const isoDate = new Date(dueDate);
if (!isNaN(isoDate.getTime())) {
parsedDueDate = isoDate;
} else {
const parts = dueDate.split('.');
if (parts.length === 3) {
const [day, month, year] = parts;
parsedDueDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}
}
}
}
// Extract parentTaskId from contract-number field before creating task
let parentTaskId = baseTaskData.parentTaskId || null;
const formFieldsForNorm = await storage.getFormFields(formId, req.organizationId!);
// Validate conditional required fields before creating task
const formStatusesForValidation = await storage.getFormStatuses(formId, req.organizationId!);
const validationErrors = await validateRequiredFields({
task: { currentStatusId: baseTaskData.currentStatusId, formId },
formFields: formFieldsForNorm,
customFields,
organizationId: req.organizationId!,
storage,
isCreation: true,
statuses: formStatusesForValidation,
});
if (validationErrors.length > 0) {
return res.status(400).json({ success: false, error: validationErrors.join('\n') });
}
const normalizedCustomFieldsPre = normalizeCustomFields(customFields, formFieldsForNorm);
// Pre-create automation hook: allows automations to block task creation
const preCreateResults = await runAutomationsByTrigger(req.organizationId!, 'task.before_create', {
formId,
task: {
formId,
title: baseTaskData.title,
currentStatusId: baseTaskData.currentStatusId,
dueDate: parsedDueDate,
customFields: normalizedCustomFieldsPre,
},
});
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: result.error || 'Создание задачи запрещено автоматизацией',
automationId: run.automationId,
activeRentalTaskId: result.activeRentalTaskId,
assignedToName: result.assignedToName,
});
}
}
if (Object.keys(normalizedCustomFieldsPre).length > 0) {
for (const [fieldKey, value] of Object.entries(normalizedCustomFieldsPre)) {
const fieldIdMatch = fieldKey.match(/^customField_(\d+)$/);
if (fieldIdMatch && value !== undefined && value !== '') {
const fieldId = parseInt(fieldIdMatch[1]);
const field = formFieldsForNorm.find(f => f.id === fieldId);
if (field?.type === 'contract-number') {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (parsed?.legalEntityTaskId) {
parentTaskId = parsed.legalEntityTaskId;
break;
}
}
}
}
}
// Автор: пользователь (JWT) или владелец API-ключа
const createdByUserId = req.user?.id ?? req.apiKey?.createdBy;
if (!createdByUserId) {
return res.status(401).json({ success: false, error: 'Не удалось определить автора задачи' });
}
const taskData = {
...baseTaskData,
formId,
organizationId: req.organizationId!,
createdBy: createdByUserId,
dueDate: parsedDueDate,
parentTaskId,
};
let task = await storage.createTask(taskData);
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
// 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(() => {});
}
// Auto-assign task based on transition rules from current status
if (!task.assignedTo && task.currentStatusId) {
const transitions = await storage.getStatusTransitions(formId, req.organizationId!);
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>;
// New format: first user assignee
if (conditions.assignees && Array.isArray(conditions.assignees)) {
const firstUser = conditions.assignees.find((a: any) => a.type === 'user');
if (firstUser?.id) {
targetUserId = firstUser.id;
break;
}
}
// Old format fallback
if (conditions.fallbackAssigneeUserId) {
targetUserId = conditions.fallbackAssigneeUserId as number;
break;
}
// Backward-compat direct assigneeId
if (conditions.assigneeId) {
targetUserId = conditions.assigneeId as number;
break;
}
}
}
if (targetUserId) {
await storage.updateTask(task.id, req.organizationId!, { assignedTo: targetUserId });
await storage.addTaskAssignee(task.id, targetUserId, req.organizationId!).catch(() => {});
// Refresh local task object so the code below sees the new assignee
const refreshed = await storage.getTask(task.id, req.organizationId!);
if (refreshed) task = refreshed;
}
}
const creatorActor = await resolveRestActor(req, req.organizationId!);
storage.addTaskAuditLog({
taskId: task.id,
organizationId: req.organizationId!,
action: 'task.created',
changedBy: creatorActor.changedBy,
changedByName: creatorActor.changedByName,
botId: creatorActor.botId,
metadata: { title: task.title, ...(creatorActor.source ? { source: creatorActor.source } : {}) },
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
if (customFields && typeof customFields === 'object') {
const formFields = await storage.getFormFields(formId, req.organizationId!);
const normalizedCustomFields = normalizeCustomFields(customFields, formFields);
const fieldMap = new Map(formFields.map(f => [f.id, f]));
const fieldValues = [];
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) {
console.warn(`Field ${fieldId} not found for form ${formId}`);
continue;
}
if (field.type === 'file' && value != null) {
const fileArr = Array.isArray(value) ? value : [];
if (field.maxFileCount != null && fileArr.length > field.maxFileCount) {
return res.status(400).json({
success: false,
error: `Поле В«${field.name}В»: превышено максимальное количество файлов (${field.maxFileCount})`,
});
}
if (field.maxFileSizeMB != null) {
const totalBytes = fileArr.reduce((sum: number, f: any) => sum + (Number(f.size) || 0), 0);
const limitBytes = field.maxFileSizeMB * 1024 * 1024;
if (totalBytes > limitBytes) {
return res.status(400).json({
success: false,
error: `Поле В«${field.name}В»: суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)`,
});
}
}
}
// Auto-generate contract number for 'mine' mode
let finalValue = normalizeFieldValueForStorage(value, field.type);
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key> → относительные
if (field.type === 'file') {
finalValue = normalizeFileFieldUrls(finalValue) as typeof finalValue;
}
if (field.type === 'contract-number') {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (parsed && parsed.mode === 'mine' && (!parsed.value || parsed.value === '')) {
const config = (field.options as any)?.contractNumber;
if (config && parsed.legalEntityTaskId) {
const generated = await generateContractNumber({
organizationId: req.organizationId!,
formId,
legalEntityTaskId: parsed.legalEntityTaskId,
prefixFieldCode: config.prefixFieldCode,
startNumber: config.startNumber ?? 1,
sequencePadding: config.sequencePadding ?? 1,
});
finalValue = JSON.stringify({ mode: 'mine', value: generated, legalEntityTaskId: parsed.legalEntityTaskId });
}
}
}
const valueData = {
taskId: task.id,
fieldId,
formId,
value: finalValue
};
const createdValue = await storage.createTaskFieldValue(valueData);
fieldValues.push(createdValue);
}
}
}
let finalTask = task;
if (existingForm.titleTemplate) {
const [allFormFields, savedValues, titleStatuses, titleUsers, titleRoles] = await Promise.all([
storage.getFormFields(formId, req.organizationId!),
storage.getTaskFieldValues(task.id, req.organizationId!),
storage.getFormStatuses(formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getRoles(req.organizationId!),
]);
const fieldCodeMap = new Map(allFormFields.map(f => [f.code, f]));
const valueByFieldId = new Map(savedValues.map(v => [v.fieldId, v.value]));
const usersMap = new Map(titleUsers.map(u => [u.id, u]));
const rolesMap = new Map(titleRoles.map(r => [r.id, r]));
const sysValues = await buildSystemFieldValues(task, req.organizationId!, { statuses: titleStatuses, usersMap });
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, allFormFields, req.organizationId!);
const tableRowMap = await buildTableRowMap(allFormFields, req.organizationId!);
const computedTitle = existingForm.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, 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);
});
finalTask = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle });
}
if (finalTask.assignedTo && finalTask.assignedTo !== req.user?.id) {
notifyTaskAssigned(finalTask, finalTask.assignedTo, req.user?.id ?? null, req.organizationId!)
.catch((err) => console.error('[TaskCreate] notifyTaskAssigned error:', err));
}
if (customFields && req.user?.id) {
const formFieldsForUpload = await storage.getFormFields(formId, req.organizationId!);
const normalizedUploadFields = normalizeCustomFields(customFields, formFieldsForUpload);
for (const [fieldKey, value] of Object.entries(normalizedUploadFields)) {
const match = fieldKey.match(/^customField_(\d+)$/);
if (match && Array.isArray(value)) {
const fid = parseInt(match[1]);
const committedUrls = (value as Array<Record<string, unknown>>).map(f => String(f.url)).filter(Boolean);
if (committedUrls.length > 0) {
commitPendingUploads(req.user!.id, fid, committedUrls);
}
}
}
}
// Evaluate auto-transitions based on entry conditions on statuses
const autoResult = await evaluateAutoTransitions(finalTask.id, req.organizationId!, { triggeredBy: req.user?.id ?? null });
if (autoResult.changed) {
const refreshed = await storage.getTask(finalTask.id, req.organizationId!);
if (refreshed) finalTask = refreshed;
}
// Notify other users in real-time that a new task was created
eventBus.publishEvent({
type: 'task_updated',
organizationId: req.organizationId!,
data: { taskId: finalTask.id, formId: finalTask.formId, task: finalTask }
});
indexTaskAsync(finalTask.id, req.organizationId!).catch(() => {});
res.status(201).json({
success: true,
message: 'Задача создана',
task: finalTask
});
} catch (error) {
console.error('Create task error:', error);
res.status(500).json({ success: false, error: 'Ошибка при создании задачи' });
}
}
);
// Check whether a task can be created given the provided prefill data.
// Runs task.before_create automations without actually creating a task.
router.post('/api/forms/:id/tasks/check-create', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const canAccessForm = await storage.canUserAccessForm(req.user!.id, formId, req.organizationId!, 'participate');
if (!canAccessForm) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' });
}
const { customFields, title, dueDate, currentStatusId } = req.body;
let parsedDueDate: Date | null = null;
if (dueDate && dueDate !== '') {
if (dueDate instanceof Date) {
parsedDueDate = dueDate;
} else if (typeof dueDate === 'string') {
const isoDate = new Date(dueDate);
if (!isNaN(isoDate.getTime())) {
parsedDueDate = isoDate;
} else {
const parts = dueDate.split('.');
if (parts.length === 3) {
const [day, month, year] = parts;
parsedDueDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}
}
}
}
const formFieldsForNorm = await storage.getFormFields(formId, req.organizationId!);
const normalizedCustomFields = normalizeCustomFields(customFields, formFieldsForNorm);
let resolvedCurrentStatusId: number | undefined = currentStatusId;
if (!resolvedCurrentStatusId) {
const formStatuses = await storage.getFormStatuses(formId, req.organizationId!);
const initialStatus = formStatuses.find(s => s.isInitial);
resolvedCurrentStatusId = initialStatus?.id;
}
if (!resolvedCurrentStatusId) {
return res.status(400).json({ success: false, error: 'Не найден начальный статус для формы' });
}
const preCreateResults = await runAutomationsByTrigger(req.organizationId!, 'task.before_create', {
formId,
task: {
formId,
title: title || existingForm.name || 'Новая запись',
currentStatusId: resolvedCurrentStatusId,
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(200).json({
success: true,
allowed: false,
automationId: run.automationId,
error: result.error || 'Создание задачи запрещено автоматизацией',
activeRentalTaskId: result.activeRentalTaskId,
assignedToName: result.assignedToName,
});
}
}
return res.status(200).json({ success: true, allowed: true });
} catch (error) {
console.error('Check create error:', error);
return res.status(500).json({ success: false, error: 'Ошибка при проверке создания задачи' });
}
});
router.get('/api/tasks/:id',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Неверный ID задачи' });
}
const task = await storage.getTask(taskId, req.organizationId!);
if (!task) {
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
res.json({ success: true, task });
} catch (error) {
console.error('Get task error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении задачи' });
}
}
);
router.get('/api/tasks/:id/detail',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Неверный ID задачи' });
}
const detail = await storage.getTaskDetail(taskId, req.organizationId!);
if (!detail) {
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.json({ success: true, ...detail });
} catch (error) {
console.error('Get task detail error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении данных задачи' });
}
}
);
router.put('/api/tasks/:id',
async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Неверный ID задачи' });
}
const existingTask = await storage.getTask(taskId, req.organizationId!);
if (!existingTask) {
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
{
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
}
const updateBody = { ...req.body };
const clientUpdatedAt = updateBody.updatedAt;
delete updateBody.updatedAt;
// Кастомные поля сохраняются через отдельные endpoints; исключаем из updateBody,
// чтобы они не мешали валидации и не попадали в storage.updateTask.
delete updateBody.customFields;
if (updateBody.dueDate && typeof updateBody.dueDate === 'string') {
updateBody.dueDate = new Date(updateBody.dueDate);
}
// If the request was replayed from offline queue, honour the client's
// original edit timestamp so the audit log shows when the user actually
// made the change, not when the sync happened.
const offlineEnqueuedAt = parseOfflineTimestamp(req);
// Validate conditional required fields before updating task system fields
const putFormFields = await storage.getFormFields(existingTask.formId, req.organizationId!);
const putExistingValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
const putFormStatuses = await storage.getFormStatuses(existingTask.formId, req.organizationId!);
const putValidationErrors = await validateRequiredFields({
task: { ...existingTask, ...updateBody },
formFields: putFormFields,
existingFieldValues: putExistingValues,
organizationId: req.organizationId!,
storage,
statuses: putFormStatuses,
});
if (putValidationErrors.length > 0) {
return res.status(400).json({ success: false, error: putValidationErrors.join('\n') });
}
let updatedTask: typeof existingTask | null = null;
if (clientUpdatedAt !== undefined && clientUpdatedAt !== null) {
const clientDate = new Date(clientUpdatedAt);
if (isNaN(clientDate.getTime())) {
return res.status(400).json({ success: false, error: 'Неверный формат поля updatedAt' });
}
updatedTask = await storage.updateTaskIfNotModified(taskId, req.organizationId!, updateBody, clientDate);
if (!updatedTask) {
const currentTask = await storage.getTask(taskId, req.organizationId!);
return res.status(409).json({
success: false,
error: 'Задача была изменена другим пользователем. Пожалуйста, обновите страницу.',
currentUpdatedAt: currentTask?.updatedAt ?? existingTask.updatedAt,
});
}
} else {
updatedTask = await storage.updateTask(taskId, req.organizationId!, updateBody);
}
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
// Gantt cascade shift for dueDate changes
if ('dueDate' in updateBody && updateBody.dueDate !== undefined) {
const oldDueDate = existingTask.dueDate;
const newDueDate = updateBody.dueDate as Date | null;
if (oldDueDate && newDueDate && !isNaN(newDueDate.getTime())) {
const deltaMs = calculateDateShift(oldDueDate, newDueDate);
if (deltaMs !== 0) {
shiftRelatedTasks(taskId, deltaMs, req.organizationId!, { triggeredByUserId: req.user?.id })
.catch((err: any) => console.error('Gantt cascade shift error (dueDate):', err));
}
}
}
const editorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
const resolveUserName = async (userId: number | null | undefined): Promise<string | null> => {
if (!userId) return null;
const orgUsers = await storage.getUsersByOrganization(req.organizationId!);
const u = orgUsers.find(x => x.id === userId);
if (!u) return String(userId);
return (`${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() || u.email);
};
const trackedFields: Array<{ key: 'title' | 'description' | 'assignedTo' | 'dueDate'; label: string }> = [
{ key: 'title', label: 'Заголовок' },
{ key: 'description', label: 'Описание' },
{ key: 'assignedTo', label: '<27>?сполнитель' },
{ key: 'dueDate', label: 'Срок' },
];
for (const { key, label } of trackedFields) {
if (key in updateBody) {
const oldVal = existingTask[key];
const newVal = updateBody[key];
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
const newStr = newVal === null || newVal === undefined ? '' : String(newVal);
if (oldStr !== newStr) {
let auditOldValue: string | number | null = oldVal as string | number | null;
let auditNewValue: string | number | null = newVal as string | number | null;
if (key === 'assignedTo') {
const [oldName, newName] = await Promise.all([
resolveUserName(oldVal as number | null),
resolveUserName(newVal as number | null),
]);
auditOldValue = oldName;
auditNewValue = newName;
}
storage.addTaskAuditLog({
taskId,
organizationId: req.organizationId!,
action: 'task.updated',
fieldName: label,
oldValue: auditOldValue,
newValue: auditNewValue,
changedBy: req.user?.id ?? null,
changedByName: editorName,
...(offlineEnqueuedAt ? { createdAt: offlineEnqueuedAt } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
}
}
}
// Уведомление новому ответственному, если assignedTo изменился через PUT
if (
updatedTask &&
'assignedTo' in updateBody &&
updateBody.assignedTo !== undefined &&
updateBody.assignedTo !== existingTask.assignedTo
) {
const newAssigneeId = updateBody.assignedTo as number | null;
if (newAssigneeId) {
notifyTaskAssigned(updatedTask, newAssigneeId, req.user?.id ?? null, req.organizationId!)
.catch((err) => console.error('[TaskUpdate] notifyTaskAssigned error:', err));
}
}
if (req.user?.id) {
storage.recordTaskInteractionAuto(taskId, req.user.id, req.organizationId!)
.catch((err: unknown) => { console.error('recordTaskInteraction error:', err); });
}
indexTaskAsync(taskId, req.organizationId!).catch(() => {});
// Evaluate auto-transitions after task update (e.g. assignedTo changed)
const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null });
if (autoResult.changed) {
const refreshed = await storage.getTask(taskId, req.organizationId!);
if (refreshed) updatedTask = refreshed;
}
eventBus.publishEvent({
type: 'task_updated',
organizationId: req.organizationId!,
data: { taskId, formId: updatedTask.formId, task: updatedTask }
});
res.json({ success: true, message: 'Задача обновлена', task: updatedTask });
} catch (error) {
console.error('Update task error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении задачи' });
}
}
);
router.delete('/api/tasks/:id', requirePermission('tasks.edit_all'), async (req: AuthenticatedRequest, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({ success: false, error: 'Неверный ID задачи' });
}
const existingTask = await storage.getTask(taskId, req.organizationId!);
if (!existingTask) {
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
await storage.deleteTask(taskId, req.organizationId!);
tasksMinimalCache.invalidate(`tasks:${req.organizationId}:minimal:`);
res.json({ success: true, message: 'Задача удалена' });
} catch (error) {
console.error('Delete task error:', error);
res.status(500).json({ success: false, error: 'Ошибка при удалении задачи' });
}
});
router.post('/api/forms/:id/tasks/batch', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
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: 'Необходимо передать массив задач' });
}
if (taskList.length > 100) {
return res.status(400).json({ success: false, error: 'Максимум 100 задач за один batch' });
}
const formStatusesList = await storage.getFormStatuses(formId, req.organizationId!);
const initialStatus = formStatusesList.find(s => s.isInitial);
if (!initialStatus) {
return res.status(400).json({ success: false, error: 'Не найден начальный статус для формы' });
}
// Pre-load common data once for all tasks in the batch
const [formFields, transitions, users, roles] = await Promise.all([
storage.getFormFields(formId, req.organizationId!),
storage.getStatusTransitions(formId, req.organizationId!),
storage.getUsersByOrganization(req.organizationId!),
storage.getRoles(req.organizationId!),
]);
const fieldMap = new Map(formFields.map(f => [f.id, f]));
const usersMap = new Map(users.map(u => [u.id, u]));
const rolesMap = new Map(roles.map(r => [r.id, r]));
const fieldCodeMap = new Map(formFields.map(f => [f.code, f]));
// Pre-build table row map once (used by title template and auto-transitions)
const tableRowMap = await buildTableRowMap(formFields, req.organizationId!);
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] as any;
const { customFields, dueDate, ...baseTaskData } = taskBody;
let parsedDueDate: Date | null = null;
if (dueDate && dueDate !== '') {
if (dueDate instanceof Date) {
parsedDueDate = dueDate;
} else if (typeof dueDate === 'string') {
const isoDate = new Date(dueDate);
if (!isNaN(isoDate.getTime())) {
parsedDueDate = isoDate;
} else {
const parts = dueDate.split('.');
if (parts.length === 3) {
const [day, month, year] = parts;
parsedDueDate = new Date(parseInt(year), parseInt(month) - 1, parseInt(day));
}
}
}
}
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,
organizationId: req.organizationId!,
createdBy: req.user!.id,
dueDate: parsedDueDate,
currentStatusId: initialStatus.id,
};
preparedTasks.push({ taskData, normalizedCustomFields });
}
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';
// ── 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 ?? [] };
}
}
const createdIds: number[] = [];
const fieldValuesToInsert: any[] = [];
const auditLogsToInsert: any[] = [];
const assigneesToAdd: Array<{ taskId: number; userId: number }> = [];
const autoTransitionTasks: Array<{ taskId: number; assignedTo?: number | null }> = [];
for (let i = 0; i < preparedTasks.length; i++) {
const { taskData, normalizedCustomFields } = preparedTasks[i];
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 (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 (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;
}
}
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;
eventBus.publishEvent({
type: 'task_updated',
organizationId: req.organizationId!,
data: { taskId, formId, task: finalTask }
});
if (assignedTo && assignedTo !== req.user!.id) {
notifyTaskAssigned(finalTask, assignedTo, req.user!.id, req.organizationId!)
.catch((err) => console.error('[BatchCreate] notifyTaskAssigned error:', err));
}
indexTaskAsync(taskId, req.organizationId!).catch(() => {});
}
}
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
res.status(txResult.cached ? 200 : 201).json({
success: true,
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);
const message = error instanceof Error ? error.message : 'Ошибка при пакетном создании задач';
res.status(500).json({ success: false, error: message });
}
});
}