Files
iistwin/server/routes/task-fields.routes.ts
Ильяс Султанов 26419f395f security(errors): 5xx без утечки err.message клиенту
Шаг 0.7 плана production-готовности:
- глобальный error handler: нейтральный текст + код инцидента, детали в лог
- route-уровень: зачистка err.message/String(err) в 5xx по 12 файлам,
  оригинальные ошибки логируются с контекстом
2026-09-07 20:42:39 +03:00

827 lines
40 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, 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: 'Ошибка при массовом обновлении полей' });
}
}
);
}