Шаг 0.7 плана production-готовности: - глобальный error handler: нейтральный текст + код инцидента, детали в лог - route-уровень: зачистка err.message/String(err) в 5xx по 12 файлам, оригинальные ошибки логируются с контекстом
827 lines
40 KiB
TypeScript
827 lines
40 KiB
TypeScript
import { Router } from "express";
|
||
import { storage } from "../storage";
|
||
import { authenticateToken, authenticateTokenOrApiKey, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||
import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor";
|
||
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";
|
||
import { normalizeFileFieldUrls } from "../utils/upload";
|
||
import { parseUserFieldEntries } from "../utils/user-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;
|
||
}
|
||
|
||
/**
|
||
* Резолвит значение user-поля (одиночное или массив ["user:5", "role:2"])
|
||
* в список userId для синка в task_assignees:
|
||
* - user:<id> / legacy-число → сам пользователь;
|
||
* - role:<id> → все члены роли (как в автопереходах — storage.getRoleMembersByRole).
|
||
*/
|
||
async function resolveAssigneeUserIds(value: unknown, organizationId: number): Promise<number[]> {
|
||
const entries = parseUserFieldEntries(value);
|
||
const ids = new Set<number>();
|
||
for (const entry of entries) {
|
||
if (entry.startsWith('role:')) {
|
||
const roleId = Number(entry.replace('role:', ''));
|
||
if (isNaN(roleId)) continue;
|
||
const members = await storage.getRoleMembersByRole(roleId).catch(() => []);
|
||
for (const m of members) {
|
||
if (m.userId) ids.add(m.userId);
|
||
}
|
||
continue;
|
||
}
|
||
const uid = extractUserId(entry);
|
||
if (uid) ids.add(uid);
|
||
}
|
||
return Array.from(ids);
|
||
}
|
||
|
||
export function registerTaskFieldRoutes(router: ReturnType<typeof import("express").Router>): 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',
|
||
authenticateTokenOrApiKey,
|
||
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: 'Задача не найдена' });
|
||
}
|
||
|
||
if (req.user) {
|
||
const hasAccess = await storage.canUserAccessTask(
|
||
taskId, req.user.id, req.organizationId!, req.user.appRole
|
||
);
|
||
if (!hasAccess) {
|
||
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
|
||
}
|
||
}
|
||
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
|
||
const apiKeyErrPost = checkApiKeyWriteAccess(req, existingTask.formId);
|
||
if (apiKeyErrPost) {
|
||
return res.status(403).json({ success: false, error: apiKeyErrPost });
|
||
}
|
||
|
||
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]));
|
||
|
||
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key>
|
||
// от внешних агентов сводятся к относительным /api/files/<key>
|
||
const normalizedFieldValues = (fieldValues as Array<{ fieldId: number; value: unknown }>).map((fv) =>
|
||
fieldMap.get(fv.fieldId)?.type === 'file' ? { ...fv, value: normalizeFileFieldUrls(fv.value) } : fv
|
||
);
|
||
|
||
// 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 normalizedFieldValues) {
|
||
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 fieldActor = await resolveRestActor(req, req.organizationId!);
|
||
const offlineEnqueuedAtPost = parseOfflineTimestamp(req);
|
||
for (const fieldValue of normalizedFieldValues) {
|
||
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: fieldActor.changedBy,
|
||
changedByName: fieldActor.changedByName,
|
||
botId: fieldActor.botId,
|
||
metadata: {
|
||
displayOldValue: displayOld || null,
|
||
displayNewValue: displayNew || null,
|
||
// Атрибуция канала: 'api' при изменении по API-ключу
|
||
...(fieldActor.source ? { source: fieldActor.source } : {}),
|
||
},
|
||
...(offlineEnqueuedAtPost ? { createdAt: offlineEnqueuedAtPost } : {}),
|
||
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
|
||
}
|
||
}
|
||
|
||
// Sync task_assignees from user fields so tasks appear in inbox
|
||
// (одиночное значение и мультивыбор: user:<id> → пользователь, role:<id> → члены роли)
|
||
for (const createdValue of createdValues) {
|
||
const fld = fieldMap.get(createdValue.fieldId);
|
||
if (fld?.type === 'user') {
|
||
const userIds = await resolveAssigneeUserIds(createdValue.value, req.organizationId!);
|
||
for (const userId of userIds) {
|
||
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: 'Ошибка при предпросмотре номера договора'
|
||
});
|
||
}
|
||
}
|
||
);
|
||
|
||
// Update single field value (inline editing)
|
||
router.patch('/api/tasks/:id/field-values/:fieldId',
|
||
authenticateTokenOrApiKey,
|
||
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: 'Задача не найдена' });
|
||
}
|
||
|
||
if (req.user) {
|
||
const hasAccess = await storage.canUserAccessTask(
|
||
taskId, req.user.id, req.organizationId!, req.user.appRole
|
||
);
|
||
if (!hasAccess) {
|
||
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
|
||
}
|
||
}
|
||
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
|
||
const apiKeyErrPatch = checkApiKeyWriteAccess(req, existingTask.formId);
|
||
if (apiKeyErrPatch) {
|
||
return res.status(403).json({ success: false, error: apiKeyErrPatch });
|
||
}
|
||
|
||
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 normalizedValueRaw = normalizeFieldValueForStorage(value, field.type);
|
||
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key> → относительные
|
||
const normalizedValue = field.type === 'file' ? normalizeFileFieldUrls(normalizedValueRaw) : normalizedValueRaw;
|
||
|
||
// 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', 'rich-text', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'contract-number', 'table', 'checklist', 'contact', 'company', 'task', '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<string, unknown>) => 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 patchActor = await resolveRestActor(req, req.organizationId!);
|
||
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: patchActor.changedBy,
|
||
changedByName: patchActor.changedByName,
|
||
botId: patchActor.botId,
|
||
...(patchActor.source ? { metadata: { source: patchActor.source } } : {}),
|
||
...(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.
|
||
// Семантика прежняя, обобщённая на массивы (мультивыбор): снимаем только тех,
|
||
// кто был в старом значении поля и кого нет в новом; role:<id> → члены роли.
|
||
if (field.type === 'user') {
|
||
const newUserIds = await resolveAssigneeUserIds(savedValue.value, req.organizationId!);
|
||
const oldUserIds = await resolveAssigneeUserIds(oldVal, req.organizationId!);
|
||
for (const userId of newUserIds) {
|
||
if (!oldUserIds.includes(userId)) {
|
||
await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {});
|
||
}
|
||
}
|
||
for (const userId of oldUserIds) {
|
||
if (!newUserIds.includes(userId)) {
|
||
await storage.removeTaskAssignee(taskId, userId, 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<string, unknown>);
|
||
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', 'rich-text', 'date', 'datetime', 'select', 'radio-group', 'checkbox', 'toggle', 'user', 'file', 'geo', 'contract-number', 'table', 'checklist', 'contact', 'company', 'task', '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);
|
||
const normalized = normalizeFieldValueForStorage(value, fld?.type);
|
||
// Нормализация self-URL в file-полях: абсолютные https://.../api/files/<key> → относительные
|
||
return [k, fld?.type === 'file' ? normalizeFileFieldUrls(normalized) : normalized];
|
||
});
|
||
|
||
// 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
|
||
// (одиночное значение и мультивыбор: user:<id> → пользователь, role:<id> → члены роли)
|
||
for (const sv of savedValues) {
|
||
const fld = fieldMap.get(sv.fieldId);
|
||
if (fld?.type === 'user') {
|
||
const userIds = await resolveAssigneeUserIds(sv.value, req.organizationId!);
|
||
for (const userId of userIds) {
|
||
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: 'Ошибка при массовом обновлении полей' });
|
||
}
|
||
}
|
||
);
|
||
}
|