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:
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user