feat: рейтинговые автоматизации, мультивыбор user/role-полей, график history-number

Инфраструктура автоматизаций:
- триггер task.status_changed оживлён: вызов из executeTaskTransition (fire-and-forget) + фильтр triggerConfig.statusId
- ctx.users.setFieldValue(userId, fieldCodeOrId, value): запись user_profile_field_values с аудитом; для history-number — append в user_field_history (миграция 0078)
- документация sandbox обновлена (Automations.tsx, mcp.ts, AGENTS.md) по критическому правилу 2

Мультивыбор пользователей и ролей в user-поле:
- чекбокс «Множественный выбор (пользователи и роли)» в настройках поля (form_fields.allow_multiple)
- значение — массив ['user:ID','role:ID'], обратная совместимость с одиночными/legacy значениями
- UserRoleMultiSelect в карточке задачи, реестре и диалоге создания (роли добавлены и в одиночный сценарий создания)
- синк в task_assignees итерирует массив; role:ID раскрывается в членов роли; PATCH single — diff старого/нового значений
- отображение массивов: реестр, title-шаблоны, условия полей, MCP update_task_fields
- список орг-ролей (/api/roles) загружается всем пользователям (сервер и так был открыт) — роли доступны не только админам

График history-number в профиле:
- GET /api/users/:id/field-history/:fieldId?period=week|month|quarter|year (агрегация AVG по бакетам)
- диалог с LineChart (recharts) и переключателем периодов по клику на иконку у поля
This commit is contained in:
2026-09-04 19:39:10 +03:00
parent 013e97c8d3
commit 4d65049e02
27 changed files with 983 additions and 112 deletions

View File

@@ -13,6 +13,7 @@ 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;
@@ -25,6 +26,31 @@ function extractUserId(value: unknown): number | null {
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,
@@ -275,11 +301,12 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
}
// 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 userId = extractUserId(createdValue.value);
if (userId) {
const userIds = await resolveAssigneeUserIds(createdValue.value, req.organizationId!);
for (const userId of userIds) {
await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {});
}
}
@@ -532,15 +559,21 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
}
}
// Sync task_assignees from user field so task appears in inbox
// Sync task_assignees from user field so task appears in inbox.
// Семантика прежняя, обобщённая на массивы (мультивыбор): снимаем только тех,
// кто был в старом значении поля и кого нет в новом; role:<id> → члены роли.
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(() => {});
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(() => {});
}
}
if (oldUserId && oldUserId !== newUserId) {
await storage.removeTaskAssignee(taskId, oldUserId, req.organizationId!).catch(() => {});
for (const userId of oldUserIds) {
if (!newUserIds.includes(userId)) {
await storage.removeTaskAssignee(taskId, userId, req.organizationId!).catch(() => {});
}
}
}
@@ -731,11 +764,12 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
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 userId = extractUserId(sv.value);
if (userId) {
const userIds = await resolveAssigneeUserIds(sv.value, req.organizationId!);
for (const userId of userIds) {
await storage.addTaskAssignee(taskId, userId, req.organizationId!).catch(() => {});
}
}