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

@@ -3,6 +3,7 @@ import {
userProfileFields,
userProfileFieldValues,
userProfileAuditLog,
userFieldHistory,
roleMembers,
roles,
userStatuses,
@@ -16,7 +17,7 @@ import {
type InsertUserProfileAuditLog,
} from "@shared/schema";
import { db } from "../db";
import { eq, and, desc, inArray } from "drizzle-orm";
import { eq, and, desc, inArray, sql } from "drizzle-orm";
import { UsersStorage } from "./users.storage";
export class UserProfileStorage extends UsersStorage {
@@ -121,6 +122,71 @@ export class UserProfileStorage extends UsersStorage {
return field || undefined;
}
async getUserProfileFieldByCode(code: string, organizationId: number): Promise<UserProfileField | undefined> {
const [field] = await db
.select()
.from(userProfileFields)
.where(and(eq(userProfileFields.code, code), eq(userProfileFields.organizationId, organizationId)));
return field || undefined;
}
// Запись значения поля профиля из автоматизации (ctx.users.setFieldValue):
// upsert значения, аудит с актором «Автоматизация», для полей history-number — запись в user_field_history.
async setUserProfileFieldValueFromAutomation(
userId: number,
fieldCodeOrId: string | number,
value: unknown,
organizationId: number,
taskId?: number | null
): Promise<UserProfileFieldValue> {
const user = await this.getUser(userId);
if (!user || user.organizationId !== organizationId) {
throw new Error(`Пользователь ${userId} не найден в организации`);
}
const field = typeof fieldCodeOrId === 'number'
? await this.getUserProfileFieldById(fieldCodeOrId, organizationId)
: await this.getUserProfileFieldByCode(String(fieldCodeOrId), organizationId);
if (!field) {
throw new Error(`Поле профиля "${fieldCodeOrId}" не найдено`);
}
const existing = await this.getUserProfileFieldValue(userId, field.id);
const saved = existing
? await this.updateUserProfileFieldValue(userId, field.id, { value: value as UserProfileFieldValue['value'] })
: await this.createUserProfileFieldValue({ userId, fieldId: field.id, value: value as InsertUserProfileFieldValue['value'] });
await this.createUserProfileAuditLog({
userId,
organizationId,
action: 'field.changed',
fieldId: field.id,
fieldName: field.name,
oldValue: existing?.value ?? null,
newValue: typeof value === 'string' ? value : value != null ? JSON.stringify(value) : null,
changedBy: null,
changedByName: 'Автоматизация',
metadata: { source: 'automation', taskId: taskId ?? null },
});
// Для полей с историей дополнительно пишем числовое значение в user_field_history
if (field.type === 'history-number') {
const num = typeof value === 'number' ? value : parseFloat(String(value).replace(',', '.').trim());
if (!Number.isFinite(num)) {
throw new Error(`Поле "${field.name}" имеет тип history-number: значение "${String(value)}" не является числом`);
}
await db.insert(userFieldHistory).values({
organizationId,
userId,
fieldId: field.id,
value: String(num),
taskId: taskId ?? null,
});
}
return saved;
}
async bulkSaveUserProfileFields(
organizationId: number,
fieldIdsToDelete: number[],
@@ -347,6 +413,49 @@ export class UserProfileStorage extends UsersStorage {
return entry;
}
// ========== FIELD HISTORY ==========
// Агрегация истории числового поля (history-number) по временным бакетам
// для графика в профиле пользователя. Пустые бакеты не возвращаются.
async getUserFieldHistoryPoints(
userId: number,
fieldId: number,
organizationId: number,
days: number,
bucket: 'day' | 'week' | 'month'
): Promise<{
points: { date: string; avg: number; count: number }[];
overallAvg: number | null;
totalCount: number;
}> {
const pointsResult = await db.execute(sql`
SELECT to_char(date_trunc(${bucket}, created_at), 'YYYY-MM-DD') AS date,
AVG(value)::float8 AS avg,
COUNT(*)::int AS count
FROM user_field_history
WHERE user_id = ${userId}
AND field_id = ${fieldId}
AND organization_id = ${organizationId}
AND created_at >= now() - make_interval(days => ${days})
GROUP BY 1
ORDER BY 1
`);
const totalsResult = await db.execute(sql`
SELECT AVG(value)::float8 AS overall_avg, COUNT(*)::int AS total_count
FROM user_field_history
WHERE user_id = ${userId}
AND field_id = ${fieldId}
AND organization_id = ${organizationId}
AND created_at >= now() - make_interval(days => ${days})
`);
const points = pointsResult.rows as Array<{ date: string; avg: number; count: number }>;
const totals = (totalsResult.rows as Array<{ overall_avg: number | null; total_count: number }>)[0];
return {
points,
overallAvg: totals?.overall_avg ?? null,
totalCount: totals?.total_count ?? 0,
};
}
// ========== DETAIL ==========
async getUserProfileDetail(
userId: number,