- extractFileKey понимает абсолютные URL (по pathname) — превью работает для уже записанных файлов - normalizeFileUrl: абсолютные self-URL нормализуются в относительные при записи file-полей (REST field-values, MCP update_task_fields/upload-инструменты, bot-api, create task)
1060 lines
48 KiB
TypeScript
1060 lines
48 KiB
TypeScript
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 });
|
||
}
|
||
});
|
||
}
|