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

@@ -29,6 +29,7 @@ const ALLOWED_STORAGE_METHODS = new Set([
'getUser',
'getUsersByOrganization',
'updateUser',
'setUserProfileFieldValueFromAutomation',
]);
interface WorkerStorageCall {
@@ -158,6 +159,13 @@ export async function runAutomationsByTrigger(
if (Number(configFormId) !== Number(dataFormId)) continue;
}
// Для task.status_changed: triggerConfig.statusId ограничивает срабатывание
// переходом ТОЛЬКО в указанный статус (без statusId — на любую смену статуса)
if (trigger === 'task.status_changed' && config.statusId !== undefined && triggerData && typeof triggerData === 'object') {
const data = triggerData as Record<string, unknown>;
if (Number(config.statusId) !== Number(data.newStatusId)) continue;
}
const { logs, error, result } = await runAutomationInWorker(
automation.code,
organizationId,

View File

@@ -391,6 +391,17 @@ export function formatFieldValueForTitle(val: any, fieldType?: string, ctx?: Fie
switch (fieldType) {
case 'user': {
// Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую
let parsedUserVal: any = val;
if (typeof val === 'string' && val.startsWith('[')) {
try { parsedUserVal = JSON.parse(val); } catch { parsedUserVal = val; }
}
if (Array.isArray(parsedUserVal)) {
return parsedUserVal
.map((entry: unknown) => formatFieldValueForTitle(String(entry), 'user', ctx))
.filter(Boolean)
.join(', ');
}
if (rawStr.startsWith('user:')) {
const uid = Number(rawStr.replace('user:', ''));
const user = ctx?.usersMap?.get(uid) ?? (ctx?.usersMap as any)?.get(String(uid));

View File

@@ -345,6 +345,30 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
}
});
// Отображаемое имя одного элемента user-поля (`user:<id>` / `role:<id>` / legacy-число)
function resolveUserColumnEntry(
entry: string,
userMap: Map<number, User>,
roleMap?: Map<number, { name?: string }>
): string {
if (entry.startsWith('user:')) {
const uid = Number(entry.replace('user:', ''));
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry;
}
if (entry.startsWith('role:')) {
const rid = Number(entry.replace('role:', ''));
const r = roleMap?.get(rid);
return r?.name ? `Роль: ${r.name}` : entry;
}
const uid = Number(entry);
if (!isNaN(uid)) {
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : entry;
}
return entry;
}
function getTaskColumnValue(
task: Task & { fieldValues?: Record<number, unknown>; assignees?: Array<{ userId: number; user?: { firstName: string | null; middleName: string | null; lastName: string | null } }> },
column: string,
@@ -406,17 +430,18 @@ export function registerTaskCrudListRoutes(router: ReturnType<typeof import("exp
const str = String(v);
switch (field?.type) {
case 'user': {
if (str.startsWith('user:')) {
const uid = Number(str.replace('user:', ''));
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : str;
// Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую
let parsedUserVal: unknown = v;
if (typeof v === 'string' && v.startsWith('[')) {
try { parsedUserVal = JSON.parse(v); } catch { parsedUserVal = v; }
}
const uid = Number(str);
if (!isNaN(uid)) {
const u = userMap.get(uid);
return u ? `${u.firstName || ''} ${u.middleName || ''} ${u.lastName || ''}`.trim() : str;
if (Array.isArray(parsedUserVal)) {
return parsedUserVal
.map((entry) => resolveUserColumnEntry(String(entry), userMap, roleMap))
.filter(Boolean)
.join(', ');
}
return str;
return resolveUserColumnEntry(str, userMap, roleMap);
}
case 'role': {
if (str.startsWith('role:')) {

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(() => {});
}
}

View File

@@ -175,6 +175,60 @@ export function registerUserProfileRoutes(router: Router): void {
}
);
// === USER FIELD HISTORY (history-number) ===
const FIELD_HISTORY_PERIODS: Record<string, { days: number; bucket: 'day' | 'week' | 'month' }> = {
week: { days: 7, bucket: 'day' },
month: { days: 30, bucket: 'day' },
quarter: { days: 90, bucket: 'week' },
year: { days: 365, bucket: 'month' },
};
router.get('/api/users/:id/field-history/:fieldId',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
const fieldId = parseInt(req.params.fieldId);
if (isNaN(userId) || isNaN(fieldId)) {
return res.status(400).json({ success: false, error: 'Неверный ID' });
}
// Права — как у просмотра профиля: любой сотрудник той же организации
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const field = await storage.getUserProfileFieldById(fieldId, req.organizationId!);
if (!field || field.type !== 'history-number') {
return res.status(404).json({ success: false, error: 'Поле не найдено' });
}
const periodParam = String(req.query.period || 'month');
const period = FIELD_HISTORY_PERIODS[periodParam];
if (!period) {
return res.status(400).json({ success: false, error: 'Неверный период. Допустимо: week, month, quarter, year' });
}
const { points, overallAvg, totalCount } = await storage.getUserFieldHistoryPoints(
userId,
fieldId,
req.organizationId!,
period.days,
period.bucket
);
res.json({
success: true,
field: { id: field.id, name: field.name },
period: periodParam,
points,
overallAvg,
totalCount,
});
} catch (error) {
console.error('Get user field history error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении истории поля' });
}
}
);
// === USER PROFILE FIELD VALUES ===
router.get('/api/users/:id/field-values',
authenticateToken,