Files
iistwin/server/routes/task-crud-write.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

935 lines
42 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 { 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 { 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";
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: 'Форма не найдена' });
}
const canAccessForm = await storage.canUserAccessForm(req.user!.id, formId, req.organizationId!, 'participate');
if (!canAccessForm) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' });
}
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;
}
}
}
}
}
const taskData = {
...baseTaskData,
formId,
organizationId: req.organizationId!,
createdBy: req.user!.id,
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 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((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);
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, 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 } = req.body;
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!);
const createdIds: number[] = [];
for (let i = 0; i < taskList.length; i++) {
const taskBody = taskList[i];
const { customFields, dueDate, ...baseTaskData } = taskBody;
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));
}
}
}
}
const taskData = {
...baseTaskData,
formId,
organizationId: req.organizationId!,
createdBy: req.user!.id,
dueDate: parsedDueDate,
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('; ')}`,
});
}
let task = await storage.createTask(taskData);
// 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)
});
}
}
}
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 });
await storage.addTaskAssignee(task.id, targetUserId, req.organizationId!).catch(() => {});
const refreshed = await storage.getTask(task.id, req.organizationId!);
if (refreshed) task = refreshed;
}
}
// 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(() => {});
// 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 (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));
}
}
tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`);
res.status(201).json({
success: true,
message: `Создано ${createdIds.length} задач`,
created: createdIds.length,
taskIds: createdIds
});
} catch (error) {
console.error('Batch create tasks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при пакетном создании задач' });
}
});
}