import { Router } from "express"; import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { buildSystemFieldValues, buildTableRowMap, deleteRemovedFiles, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared"; import { evaluateAutoTransitions } from "../utils/auto-transitions"; import { tasksMinimalCache } from "../utils/cache"; import { indexTaskAsync, parseOfflineTimestamp } from "./task-helpers"; import { previewContractNumber } from "../services/contract-number.service"; import { validateRequiredFields } from "../utils/validate-required-fields"; import { validateImmutableFields } from "../utils/validate-immutable-fields"; import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service"; import { normalizeFieldValueForStorage } from "../utils/normalize-field-value"; function extractUserId(value: unknown): number | null { if (value === null || value === undefined || value === '') return null; const raw = String(value); if (raw.startsWith('user:')) { const uid = Number(raw.replace('user:', '')); return isNaN(uid) ? null : uid; } const uid = Number(raw); return isNaN(uid) ? null : uid; } export function registerTaskFieldRoutes(router: ReturnType): void { router.get('/api/tasks/:id/field-values', 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 existingTask = await storage.getTask(taskId, req.organizationId!); if (!existingTask) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } const fieldValues = await storage.getTaskFieldValues(taskId, req.organizationId!); res.json({ success: true, fieldValues }); } catch (error) { console.error('Get task field values error:', error); res.status(500).json({ success: false, error: 'Ошибка при получении значений полей задачи' }); } }); // Create/Update task field values (bulk) router.post('/api/tasks/:id/field-values', 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 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 { fieldValues } = req.body; if (!Array.isArray(fieldValues)) { return res.status(400).json({ success: false, error: 'Значения полей должны быть массивом' }); } const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!); const oldValueMap = new Map(existingValues.map(v => [v.fieldId, v.value])); const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!); const fieldMap = new Map(formFields.map(f => [f.id, f])); // Pre-load data for audit log display-text resolution (and reuse later for title template) const [auditUsers, auditRoles] = await Promise.all([ storage.getUsersByOrganization(req.organizationId!), storage.getRoles(req.organizationId!), ]); const auditUsersMap = new Map(auditUsers.map(u => [u.id, u])); const auditRolesMap = new Map(auditRoles.map(r => [r.id, r])); const auditTableRowMap = await buildTableRowMap(formFields, req.organizationId!); // Validate conditional required fields before applying changes const simulatedFieldValuesPost: Array<{ fieldId: number; value: any }> = [...existingValues] as any; for (const fv of fieldValues) { const idx = simulatedFieldValuesPost.findIndex((v) => v.fieldId === fv.fieldId); if (idx >= 0) { simulatedFieldValuesPost[idx] = { ...simulatedFieldValuesPost[idx], value: fv.value }; } else { simulatedFieldValuesPost.push({ fieldId: fv.fieldId, value: fv.value }); } } const auditTaskTitleMap = await resolveTaskFieldTitles( new Map(simulatedFieldValuesPost.map(v => [v.fieldId, v.value])), formFields, req.organizationId! ); const postFormStatuses = await storage.getFormStatuses(existingTask.formId, req.organizationId!); const postValidationErrors = await validateRequiredFields({ task: existingTask, formFields, existingFieldValues: simulatedFieldValuesPost, organizationId: req.organizationId!, storage, statuses: postFormStatuses, }); if (postValidationErrors.length > 0) { return res.status(400).json({ success: false, error: postValidationErrors.join('\n') }); } const changedFieldIdsPost = fieldValues.map((fv: any) => fv.fieldId as number); const immutableErrorsPost = await validateImmutableFields({ task: existingTask, formFields, changedFieldIds: changedFieldIdsPost, existingFieldValues: simulatedFieldValuesPost, organizationId: req.organizationId!, storage, statuses: postFormStatuses, }); if (immutableErrorsPost.length > 0) { return res.status(400).json({ success: false, error: immutableErrorsPost.join('\n') }); } const replaceAll = req.query.replace === 'true'; if (replaceAll) { // Legacy behavior: delete all existing values before creating new ones for (const value of existingValues) { await storage.deleteTaskRelationByField(value.fieldId, taskId, req.organizationId!); await storage.deleteTaskFieldValue(taskId, value.fieldId, req.organizationId!); } } const createdValues = []; for (const fieldValue of fieldValues) { const field = fieldMap.get(fieldValue.fieldId); const normalizedValue = normalizeFieldValueForStorage(fieldValue.value, field?.type); if (replaceAll) { const valueData = { taskId, fieldId: fieldValue.fieldId, formId: existingTask.formId, value: normalizedValue }; const createdValue = await storage.createTaskFieldValue(valueData); createdValues.push(createdValue); } else { // Partial update: upsert only provided fields const hasExisting = oldValueMap.has(fieldValue.fieldId); // Clean up old task relation if this is a task field if (field?.type === 'task' && hasExisting) { await storage.deleteTaskRelationByField(fieldValue.fieldId, taskId, req.organizationId!); } if (normalizedValue === null || normalizedValue === undefined || normalizedValue === '') { if (hasExisting) { await storage.deleteTaskFieldValue(taskId, fieldValue.fieldId, req.organizationId!); } } else { let savedValue; if (hasExisting) { savedValue = await storage.updateTaskFieldValue(taskId, fieldValue.fieldId, req.organizationId!, { value: normalizedValue }); } else { const valueData = { taskId, fieldId: fieldValue.fieldId, formId: existingTask.formId, value: normalizedValue }; savedValue = await storage.createTaskFieldValue(valueData); } createdValues.push(savedValue); } } // Create task relation if applicable if (field?.type === 'task' && field?.taskRelationType && fieldValue.value) { const selectedTaskId = parseInt(String(fieldValue.value)); if (!isNaN(selectedTaskId)) { if (field.taskRelationType === 'parent') { await storage.upsertTaskRelation({ parentTaskId: selectedTaskId, childTaskId: taskId, fieldId: fieldValue.fieldId, organizationId: req.organizationId!, }); } else if (field.taskRelationType === 'child') { await storage.upsertTaskRelation({ parentTaskId: taskId, childTaskId: selectedTaskId, fieldId: fieldValue.fieldId, organizationId: req.organizationId!, }); } } } } const fieldEditorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; const offlineEnqueuedAtPost = parseOfflineTimestamp(req); for (const fieldValue of fieldValues) { const field = fieldMap.get(fieldValue.fieldId); if (!field) continue; const oldVal = oldValueMap.has(fieldValue.fieldId) ? oldValueMap.get(fieldValue.fieldId) : undefined; const newVal = normalizeFieldValueForStorage(fieldValue.value, field.type); const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal); const newStr = newVal === null || newVal === undefined ? '' : String(newVal); if (oldStr !== newStr) { const titleCtx = { usersMap: auditUsersMap, rolesMap: auditRolesMap, taskTitleMap: auditTaskTitleMap, tableRowMap: auditTableRowMap }; const displayOld = formatFieldValueForTitle(oldVal, field.type, titleCtx, field.options); const displayNew = formatFieldValueForTitle(newVal, field.type, titleCtx, field.options); storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'field.changed', fieldId: field.id, fieldName: field.name, oldValue: oldVal ?? null, newValue: newVal ?? null, changedBy: req.user?.id ?? null, changedByName: fieldEditorName, metadata: { displayOldValue: displayOld || null, displayNewValue: displayNew || null, }, ...(offlineEnqueuedAtPost ? { createdAt: offlineEnqueuedAtPost } : {}), }).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); }); } } // Sync task_assignees from user fields so tasks appear in inbox for (const createdValue of createdValues) { const fld = fieldMap.get(createdValue.fieldId); if (fld?.type === 'user') { const userId = extractUserId(createdValue.value); if (userId) { await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {}); } } } const taskFormPost = await storage.getForm(existingTask.formId, req.organizationId!); if (taskFormPost?.titleTemplate) { const [allValsPost, postStatuses, postUsers, postRoles] = await Promise.all([ storage.getTaskFieldValues(taskId, req.organizationId!), storage.getFormStatuses(existingTask.formId, req.organizationId!), storage.getUsersByOrganization(req.organizationId!), storage.getRoles(req.organizationId!), ]); const valByFieldIdPost = new Map(allValsPost.map(v => [v.fieldId, v.value])); const fieldCodeMapPost = new Map(formFields.map(f => [f.code, f])); const postUsersMap = new Map(postUsers.map(u => [u.id, u])); const postRolesMap = new Map(postRoles.map(r => [r.id, r])); const sysValuesPost = await buildSystemFieldValues(existingTask, req.organizationId!, { statuses: postStatuses, usersMap: postUsersMap }); const taskTitleMapPost = await resolveTaskFieldTitles(valByFieldIdPost, formFields, req.organizationId!); const tableRowMapPost = await buildTableRowMap(formFields, req.organizationId!); const computedTitlePost = taskFormPost.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => { if (sysValuesPost.has(code)) return sysValuesPost.get(code)!; const f = fieldCodeMapPost.get(code); if (!f) return ''; const val = valByFieldIdPost.get(f.id); if (val === null || val === undefined) return ''; if (val === '__auto_prolongation__' && (f.type === 'date' || f.type === 'datetime')) { return f.autoProlongationLabel || 'Автопролонгация'; } return formatFieldValueForTitle(val, f.type, { usersMap: postUsersMap, rolesMap: postRolesMap, taskTitleMap: taskTitleMapPost, tableRowMap: tableRowMapPost }, f.options); }); console.error('[TITLE DEBUG] POST field-values: usersMap size=', postUsersMap.size, 'computedTitle=', computedTitlePost); await storage.updateTask(taskId, req.organizationId!, { title: computedTitlePost }); } 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(() => {}); res.status(201).json({ success: true, message: 'Значения полей сохранены', fieldValues: createdValues }); } catch (error) { console.error('Save task field values error:', error); res.status(500).json({ success: false, error: 'Ошибка при сохранении значений полей' }); } } ); // Preview contract number (without reserving counter) router.post('/api/tasks/preview-contract-number', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { formId, legalEntityTaskId, fieldId } = req.body; if (!formId || !legalEntityTaskId || !fieldId) { return res.status(400).json({ success: false, error: 'Необходимы formId, legalEntityTaskId и fieldId' }); } const formField = await storage.getFormFieldById(fieldId, req.organizationId!); if (!formField || formField.type !== 'contract-number') { return res.status(400).json({ success: false, error: 'Поле не найдено или не является полем номера договора' }); } const config = (formField.options as any)?.contractNumber; if (!config) { return res.status(400).json({ success: false, error: 'Поле номера договора не настроено' }); } const result = await previewContractNumber({ organizationId: req.organizationId!, formId, legalEntityTaskId, config, }); res.json({ success: true, ...result }); } catch (error) { console.error('Preview contract number error:', error); res.status(500).json({ success: false, error: error instanceof Error ? error.message : 'Ошибка при предпросмотре номера договора' }); } } ); // Update single field value (inline editing) router.patch('/api/tasks/:id/field-values/:fieldId', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.id); const fieldId = parseInt(req.params.fieldId); if (isNaN(taskId) || isNaN(fieldId)) { 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 { value } = req.body; const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!); const field = formFields.find(f => f.id === fieldId); if (!field) { return res.status(404).json({ success: false, error: 'Поле не найдено' }); } const normalizedValue = normalizeFieldValueForStorage(value, field.type); // Validate conditional required fields before applying the change const existingValuesSingle = await storage.getTaskFieldValues(taskId, req.organizationId!); const simulatedFieldValuesSingle = existingValuesSingle.map((v: any) => v.fieldId === fieldId ? { ...v, value: normalizedValue } : v ); if (!simulatedFieldValuesSingle.some((v: any) => v.fieldId === fieldId)) { simulatedFieldValuesSingle.push({ fieldId, value: normalizedValue }); } const singleFormStatuses = await storage.getFormStatuses(existingTask.formId, req.organizationId!); const singleValidationErrors = await validateRequiredFields({ task: existingTask, formFields, existingFieldValues: simulatedFieldValuesSingle, organizationId: req.organizationId!, storage, statuses: singleFormStatuses, fieldIdsToCheck: [fieldId], }); if (singleValidationErrors.length > 0) { return res.status(400).json({ success: false, error: singleValidationErrors.join('\n') }); } const immutableErrorsSingle = await validateImmutableFields({ task: existingTask, formFields, changedFieldIds: [fieldId], existingFieldValues: simulatedFieldValuesSingle, organizationId: req.organizationId!, storage, statuses: singleFormStatuses, }); if (immutableErrorsSingle.length > 0) { return res.status(400).json({ success: false, error: immutableErrorsSingle.join('\n') }); } const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'contract-number', 'table', 'checklist', 'contact', 'document_template']); if (!INLINE_EDITABLE_FIELD_TYPES.has(field.type)) { return res.status(400).json({ success: false, error: `Поле типа «${field.type}» не поддерживает inline-редактирование` }); } if (field.type === 'file' && value != null) { const newFiles = Array.isArray(value) ? value : []; if (field.maxFileCount != null && newFiles.length > field.maxFileCount) { return res.status(400).json({ success: false, error: `Превышено максимальное количество файлов (${field.maxFileCount})` }); } if (field.maxFileSizeMB != null) { const totalBytes = newFiles.reduce((sum: number, f: Record) => sum + (Number(f.size) || 0), 0); const limitBytes = field.maxFileSizeMB * 1024 * 1024; if (totalBytes > limitBytes) { return res.status(400).json({ success: false, error: `Суммарный размер файлов превышает лимит (${field.maxFileSizeMB} МБ)` }); } } } const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!); const existingValue = existingValues.find(v => v.fieldId === fieldId); const oldVal = existingValue ? existingValue.value : undefined; if (field.type === 'file' && oldVal) { await deleteRemovedFiles(oldVal, normalizedValue); } let savedValue; if (existingValue) { savedValue = await storage.updateTaskFieldValue(taskId, fieldId, req.organizationId!, { value: normalizedValue }); } else { savedValue = await storage.createTaskFieldValue({ taskId, fieldId, formId: existingTask.formId, value: normalizedValue, }); } const editorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; const offlineEnqueuedAtPatch = parseOfflineTimestamp(req); const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal); const newStr = normalizedValue === null || normalizedValue === undefined ? '' : String(normalizedValue); if (oldStr !== newStr) { storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'field.changed', fieldId: field.id, fieldName: field.name, oldValue: oldVal ?? null, newValue: normalizedValue ?? null, changedBy: req.user?.id ?? null, changedByName: editorName, ...(offlineEnqueuedAtPatch ? { createdAt: offlineEnqueuedAtPatch } : {}), }).catch((auditErr: unknown) => { console.error('Audit log error (inline edit):', auditErr); }); } tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`); // Gantt cascade shift for start/end fields if ((field.ganttRole === 'start' || field.ganttRole === 'end') && (field.type === 'date' || field.type === 'datetime')) { const oldDate = oldVal ? new Date(String(oldVal)) : null; const newDate = value ? new Date(String(value)) : null; if (oldDate && newDate && !isNaN(oldDate.getTime()) && !isNaN(newDate.getTime())) { const deltaMs = calculateDateShift(oldDate, newDate); if (deltaMs !== 0) { shiftRelatedTasks(taskId, deltaMs, req.organizationId!, { triggeredByUserId: req.user?.id }) .catch((err: any) => console.error('Gantt cascade shift error:', err)); } } } // Sync task_assignees from user field so task appears in inbox if (field.type === 'user') { const newUserId = extractUserId(savedValue.value); const oldUserId = extractUserId(oldVal); if (newUserId && newUserId !== oldUserId) { await storage.addTaskAssignee(taskId, newUserId, req.organizationId!).catch(() => {}); } if (oldUserId && oldUserId !== newUserId) { await storage.removeTaskAssignee(taskId, oldUserId, req.organizationId!).catch(() => {}); } } const taskFormSingle = await storage.getForm(existingTask.formId, req.organizationId!); if (taskFormSingle?.titleTemplate) { const [allVals, singleStatuses, singleUsers, singleRoles] = await Promise.all([ storage.getTaskFieldValues(taskId, req.organizationId!), storage.getFormStatuses(existingTask.formId, req.organizationId!), storage.getUsersByOrganization(req.organizationId!), storage.getRoles(req.organizationId!), ]); const valByFieldId = new Map(allVals.map(v => [v.fieldId, v.value])); const fieldCodeMapSingle = new Map(formFields.map(f => [f.code, f])); const singleUsersMap = new Map(singleUsers.map(u => [u.id, u])); const singleRolesMap = new Map(singleRoles.map(r => [r.id, r])); const sysValuesSingle = await buildSystemFieldValues(existingTask, req.organizationId!, { statuses: singleStatuses, usersMap: singleUsersMap }); const taskTitleMapSingle = await resolveTaskFieldTitles(valByFieldId, formFields, req.organizationId!); const tableRowMapSingle = await buildTableRowMap(formFields, req.organizationId!); const computedTitle = taskFormSingle.titleTemplate.replace(/\{\{([^}]+)\}\}/g, (_, code: string) => { if (sysValuesSingle.has(code)) return sysValuesSingle.get(code)!; const f = fieldCodeMapSingle.get(code); if (!f) return ''; const val = valByFieldId.get(f.id); if (val === null || val === undefined) return ''; if (val === '__auto_prolongation__' && (f.type === 'date' || f.type === 'datetime')) { return f.autoProlongationLabel || 'Автопролонгация'; } return formatFieldValueForTitle(val, f.type, { usersMap: singleUsersMap, rolesMap: singleRolesMap, taskTitleMap: taskTitleMapSingle, tableRowMap: tableRowMapSingle }, f.options); }); console.error('[TITLE DEBUG] PATCH field-values/:fieldId: usersMap size=', singleUsersMap.size, 'computedTitle=', computedTitle); await storage.updateTask(taskId, req.organizationId!, { title: computedTitle }); } 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(() => {}); const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null }); const freshTask = await storage.getTask(taskId, req.organizationId!); eventBus.publishEvent({ type: 'task_updated', organizationId: req.organizationId!, data: { taskId, formId: existingTask.formId, task: freshTask, autoTransition: autoResult.changed } }); res.json({ success: true, fieldValue: savedValue }); } catch (error) { console.error('Inline field value update error:', error); res.status(500).json({ success: false, error: 'Ошибка при обновлении поля' }); } } ); // Bulk update multiple field values atomically router.patch('/api/tasks/:id/field-values', 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 existingTask = await storage.getTask(taskId, req.organizationId!); if (!existingTask) { return res.status(404).json({ success: false, error: 'Задача не найдена' }); } const body = req.body; if (typeof body !== 'object' || body === null || Array.isArray(body)) { return res.status(400).json({ success: false, error: 'Тело запроса должно быть объектом { fieldId: value }' }); } const entries = Object.entries(body as Record); if (entries.length === 0) { return res.json({ success: true, fieldValues: [] }); } const strictNumeric = /^\d+$/; if (entries.some(([k]) => !strictNumeric.test(k))) { return res.status(400).json({ success: false, error: 'Все ключи должны быть числовыми ID полей' }); } const fieldIds = entries.map(([k]) => parseInt(k, 10)); const INLINE_EDITABLE_FIELD_TYPES = new Set(['text', 'number', 'textarea', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'checklist', 'contact', 'document_template']); const formFields = await storage.getFormFields(existingTask.formId, req.organizationId!); const fieldMap = new Map(formFields.map(f => [f.id, f])); const existingValues = await storage.getTaskFieldValues(taskId, req.organizationId!); const existingValueMap = new Map(existingValues.map(v => [v.fieldId, v.value])); const normalizedEntries = entries.map(([k, value]): [string, unknown] => { const fid = parseInt(k, 10); const fld = fieldMap.get(fid); return [k, normalizeFieldValueForStorage(value, fld?.type)]; }); // Validate conditional required fields before applying bulk changes const simulatedFieldValuesBulk: Array<{ fieldId: number; value: any }> = existingValues.map((v: any) => { const newVal = normalizedEntries.find(([key]) => parseInt(key, 10) === v.fieldId)?.[1]; return newVal !== undefined ? { ...v, value: newVal } : v; }); for (const [k, newVal] of normalizedEntries) { const fid = parseInt(k, 10); if (!simulatedFieldValuesBulk.some((v: any) => v.fieldId === fid)) { simulatedFieldValuesBulk.push({ fieldId: fid, value: newVal }); } } const bulkFormStatuses = await storage.getFormStatuses(existingTask.formId, req.organizationId!); const bulkValidationErrors = await validateRequiredFields({ task: existingTask, formFields, existingFieldValues: simulatedFieldValuesBulk, organizationId: req.organizationId!, storage, statuses: bulkFormStatuses, }); if (bulkValidationErrors.length > 0) { return res.status(400).json({ success: false, error: bulkValidationErrors.join('\n') }); } const immutableErrorsBulk = await validateImmutableFields({ task: existingTask, formFields, changedFieldIds: fieldIds, existingFieldValues: simulatedFieldValuesBulk, organizationId: req.organizationId!, storage, statuses: bulkFormStatuses, }); if (immutableErrorsBulk.length > 0) { return res.status(400).json({ success: false, error: immutableErrorsBulk.join('\n') }); } for (const fieldId of fieldIds) { const field = fieldMap.get(fieldId); if (!field) { return res.status(404).json({ success: false, error: `Поле ${fieldId} не найдено` }); } if (!INLINE_EDITABLE_FIELD_TYPES.has(field.type)) { return res.status(400).json({ success: false, error: `Поле типа «${field.type}» не поддерживает inline-редактирование` }); } } for (const [k, newVal] of normalizedEntries) { const fid = parseInt(k, 10); const fld = fieldMap.get(fid); if (fld?.type === 'file') { const oldFileVal = existingValueMap.get(fid); if (oldFileVal) await deleteRemovedFiles(oldFileVal, newVal); } } const updates = normalizedEntries.map(([k, value]) => ({ fieldId: parseInt(k, 10), value })); const savedValues = await storage.bulkUpsertTaskFieldValues(taskId, existingTask.formId, updates); const editorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; const offlineEnqueuedAtBulk = parseOfflineTimestamp(req); for (const { fieldId, value } of updates) { const field = fieldMap.get(fieldId)!; const oldVal = existingValueMap.get(fieldId); const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal); const newStr = value === null || value === undefined ? '' : String(value); if (oldStr !== newStr) { storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'field.changed', fieldId: field.id, fieldName: field.name, oldValue: oldVal ?? null, newValue: value ?? null, changedBy: req.user?.id ?? null, changedByName: editorName, ...(offlineEnqueuedAtBulk ? { createdAt: offlineEnqueuedAtBulk } : {}), }).catch((auditErr: unknown) => { console.error('Audit log error (bulk inline edit):', auditErr); }); } } tasksMinimalCache.invalidatePrefix(`tasks:${req.organizationId}:minimal:`); // Sync task_assignees from user fields so tasks appear in inbox for (const sv of savedValues) { const fld = fieldMap.get(sv.fieldId); if (fld?.type === 'user') { const userId = extractUserId(sv.value); if (userId) { await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {}); } } } const taskForm = await storage.getForm(existingTask.formId, req.organizationId!); if (taskForm?.titleTemplate) { const [allValues, bulkStatuses, bulkUsers, bulkRoles] = await Promise.all([ storage.getTaskFieldValues(taskId, req.organizationId!), storage.getFormStatuses(existingTask.formId, req.organizationId!), storage.getUsersByOrganization(req.organizationId!), storage.getRoles(req.organizationId!), ]); const valueByFieldId = new Map(allValues.map(v => [v.fieldId, v.value])); const fieldCodeMap = new Map(formFields.map(f => [f.code, f])); const bulkUsersMap = new Map(bulkUsers.map(u => [u.id, u])); const bulkRolesMap = new Map(bulkRoles.map(r => [r.id, r])); const sysValues = await buildSystemFieldValues(existingTask, req.organizationId!, { statuses: bulkStatuses, usersMap: bulkUsersMap }); const taskTitleMapBulk = await resolveTaskFieldTitles(valueByFieldId, formFields, req.organizationId!); const tableRowMapBulk = await buildTableRowMap(formFields, req.organizationId!); const computedTitle = taskForm.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: bulkUsersMap, rolesMap: bulkRolesMap, taskTitleMap: taskTitleMapBulk, tableRowMap: tableRowMapBulk }, field.options); }); console.error('[TITLE DEBUG] PATCH field-values bulk: usersMap size=', bulkUsersMap.size, 'computedTitle=', computedTitle); await storage.updateTask(taskId, req.organizationId!, { title: computedTitle }); } indexTaskAsync(taskId, req.organizationId!).catch(() => {}); const autoResult = await evaluateAutoTransitions(taskId, req.organizationId!, { triggeredBy: req.user?.id ?? null }); const freshTask = await storage.getTask(taskId, req.organizationId!); eventBus.publishEvent({ type: 'task_updated', organizationId: req.organizationId!, data: { taskId, formId: existingTask.formId, task: freshTask, autoTransition: autoResult.changed } }); res.json({ success: true, fieldValues: savedValues }); } catch (error) { console.error('Bulk field value update error:', error); res.status(500).json({ success: false, error: 'Ошибка при массовом обновлении полей' }); } } ); }