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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user