Files
iistwin/server/storage/user-profile.storage.ts
Ильяс Султанов 4d65049e02 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) и переключателем периодов по клику на иконку у поля
2026-09-04 19:39:10 +03:00

492 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
userProfileTabs,
userProfileFields,
userProfileFieldValues,
userProfileAuditLog,
userFieldHistory,
roleMembers,
roles,
userStatuses,
type UserProfileTab,
type UserProfileField,
type UserProfileFieldValue,
type UserProfileAuditLog,
type InsertUserProfileTab,
type InsertUserProfileField,
type InsertUserProfileFieldValue,
type InsertUserProfileAuditLog,
} from "@shared/schema";
import { db } from "../db";
import { eq, and, desc, inArray, sql } from "drizzle-orm";
import { UsersStorage } from "./users.storage";
export class UserProfileStorage extends UsersStorage {
// ========== TABS ==========
async getUserProfileTabs(organizationId: number): Promise<UserProfileTab[]> {
return await db
.select()
.from(userProfileTabs)
.where(eq(userProfileTabs.organizationId, organizationId))
.orderBy(userProfileTabs.displayOrder);
}
async getUserProfileTabsByCreationOrder(organizationId: number): Promise<UserProfileTab[]> {
return await db
.select()
.from(userProfileTabs)
.where(eq(userProfileTabs.organizationId, organizationId))
.orderBy(userProfileTabs.id);
}
async bulkSaveUserProfileTabs(
organizationId: number,
tabs: Array<{ id?: number; name: string; code: string; position?: number; displayOrder?: number }>
): Promise<UserProfileTab[]> {
return await db.transaction(async (tx) => {
const existingTabs = await tx
.select()
.from(userProfileTabs)
.where(eq(userProfileTabs.organizationId, organizationId));
const incomingIds = tabs.filter((t) => t.id && t.id > 0).map((t) => t.id!);
const tabsToDelete = existingTabs.filter((e) => !incomingIds.includes(e.id));
for (const tab of tabsToDelete) {
await tx
.delete(userProfileTabs)
.where(
and(
eq(userProfileTabs.id, tab.id),
eq(userProfileTabs.organizationId, organizationId)
)
);
}
const savedTabs: UserProfileTab[] = [];
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
const tabData: InsertUserProfileTab = {
organizationId,
name: tab.name || "",
code: tab.code || `tab_${i + 1}`,
position: tab.position ?? i,
displayOrder: tab.displayOrder ?? i,
};
if (tab.id && tab.id > 0) {
const [updated] = await tx
.update(userProfileTabs)
.set(tabData)
.where(
and(
eq(userProfileTabs.id, tab.id),
eq(userProfileTabs.organizationId, organizationId)
)
)
.returning();
if (updated) savedTabs.push(updated);
} else {
const [created] = await tx
.insert(userProfileTabs)
.values(tabData)
.returning();
savedTabs.push(created);
}
}
return savedTabs;
});
}
// ========== FIELDS ==========
async getUserProfileFields(organizationId: number): Promise<UserProfileField[]> {
return await db
.select()
.from(userProfileFields)
.where(eq(userProfileFields.organizationId, organizationId))
.orderBy(userProfileFields.displayOrder);
}
async getUserProfileFieldsByCreationOrder(organizationId: number): Promise<UserProfileField[]> {
return await db
.select()
.from(userProfileFields)
.where(eq(userProfileFields.organizationId, organizationId))
.orderBy(userProfileFields.id);
}
async getUserProfileFieldById(id: number, organizationId: number): Promise<UserProfileField | undefined> {
const [field] = await db
.select()
.from(userProfileFields)
.where(and(eq(userProfileFields.id, id), eq(userProfileFields.organizationId, organizationId)));
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[],
fields: Array<{
id?: number;
name: string;
code: string;
type: string;
isRequired?: boolean;
placeholder?: string | null;
defaultValue?: string | null;
validationRules?: unknown;
options?: unknown;
position?: number | null;
displayOrder?: number | null;
tabId?: number | null;
linkedFormId?: number | null;
companyAutofill?: unknown;
maxFileCount?: number | null;
maxFileSizeMB?: number | null;
autoProlongation?: boolean | null;
autoProlongationLabel?: string | null;
}>
): Promise<UserProfileField[]> {
return await db.transaction(async (tx) => {
for (const fieldId of fieldIdsToDelete) {
await tx
.delete(userProfileFields)
.where(
and(
eq(userProfileFields.id, fieldId),
eq(userProfileFields.organizationId, organizationId)
)
);
}
const resultFields: UserProfileField[] = [];
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const fieldData: InsertUserProfileField = {
organizationId,
name: field.name,
code: field.code,
type: field.type,
isRequired: field.isRequired || false,
placeholder: field.placeholder || null,
defaultValue: field.defaultValue || null,
validationRules: field.validationRules || null,
options: field.options || null,
position: field.position ?? i,
displayOrder: field.displayOrder ?? i,
tabId: field.tabId || null,
linkedFormId: field.linkedFormId || null,
companyAutofill: field.companyAutofill || null,
maxFileCount:
field.maxFileCount != null && field.maxFileCount > 0 ? field.maxFileCount : null,
maxFileSizeMB:
field.maxFileSizeMB != null && field.maxFileSizeMB > 0 ? field.maxFileSizeMB : null,
autoProlongation: field.autoProlongation || false,
autoProlongationLabel: field.autoProlongationLabel || null,
};
if (field.id && field.id > 0) {
const [updated] = await tx
.update(userProfileFields)
.set(fieldData)
.where(
and(
eq(userProfileFields.id, field.id),
eq(userProfileFields.organizationId, organizationId)
)
)
.returning();
if (updated) resultFields.push(updated);
} else {
const [created] = await tx
.insert(userProfileFields)
.values(fieldData)
.returning();
resultFields.push(created);
}
}
return resultFields;
});
}
// ========== FIELD VALUES ==========
async getUserProfileFieldValues(userId: number): Promise<UserProfileFieldValue[]> {
return await db
.select()
.from(userProfileFieldValues)
.where(eq(userProfileFieldValues.userId, userId));
}
async getUserProfileFieldValue(userId: number, fieldId: number): Promise<UserProfileFieldValue | undefined> {
const [value] = await db
.select()
.from(userProfileFieldValues)
.where(
and(
eq(userProfileFieldValues.userId, userId),
eq(userProfileFieldValues.fieldId, fieldId)
)
);
return value || undefined;
}
async createUserProfileFieldValue(insertValue: InsertUserProfileFieldValue): Promise<UserProfileFieldValue> {
const [value] = await db
.insert(userProfileFieldValues)
.values({ ...insertValue, createdAt: new Date(), updatedAt: new Date() })
.returning();
return value;
}
async createUserProfileFieldValueWithAudit(
insertValue: InsertUserProfileFieldValue,
organizationId: number,
changedBy: number
): Promise<UserProfileFieldValue> {
const value = await this.createUserProfileFieldValue(insertValue);
await this.createUserProfileAuditLog({
userId: insertValue.userId,
organizationId,
action: 'field_value.created',
fieldId: insertValue.fieldId,
oldValue: null,
newValue: typeof insertValue.value === 'string' ? insertValue.value : JSON.stringify(insertValue.value),
changedBy,
});
return value;
}
async updateUserProfileFieldValue(
userId: number,
fieldId: number,
updates: Partial<UserProfileFieldValue>
): Promise<UserProfileFieldValue> {
const [value] = await db
.update(userProfileFieldValues)
.set({ ...updates, updatedAt: new Date() })
.where(
and(
eq(userProfileFieldValues.userId, userId),
eq(userProfileFieldValues.fieldId, fieldId)
)
)
.returning();
return value;
}
async updateUserProfileFieldValueWithAudit(
userId: number,
fieldId: number,
updates: Partial<UserProfileFieldValue>,
organizationId: number,
changedBy: number
): Promise<UserProfileFieldValue> {
const existing = await this.getUserProfileFieldValue(userId, fieldId);
const value = await this.updateUserProfileFieldValue(userId, fieldId, updates);
await this.createUserProfileAuditLog({
userId,
organizationId,
action: 'field_value.updated',
fieldId,
oldValue: existing?.value ?? null,
newValue: typeof updates.value === 'string' ? updates.value : updates.value != null ? JSON.stringify(updates.value) : null,
changedBy,
});
return value;
}
async deleteUserProfileFieldValue(userId: number, fieldId: number): Promise<void> {
await db
.delete(userProfileFieldValues)
.where(
and(
eq(userProfileFieldValues.userId, userId),
eq(userProfileFieldValues.fieldId, fieldId)
)
);
}
async deleteUserProfileFieldValueWithAudit(
userId: number,
fieldId: number,
organizationId: number,
changedBy: number
): Promise<void> {
const existing = await this.getUserProfileFieldValue(userId, fieldId);
await this.deleteUserProfileFieldValue(userId, fieldId);
if (existing) {
await this.createUserProfileAuditLog({
userId,
organizationId,
action: 'field_value.deleted',
fieldId,
oldValue: existing.value ?? null,
newValue: null,
changedBy,
});
}
}
// ========== AUDIT LOG ==========
async getUserProfileAuditLog(userId: number, organizationId: number): Promise<UserProfileAuditLog[]> {
return await db
.select()
.from(userProfileAuditLog)
.where(
and(
eq(userProfileAuditLog.userId, userId),
eq(userProfileAuditLog.organizationId, organizationId)
)
)
.orderBy(desc(userProfileAuditLog.createdAt));
}
async createUserProfileAuditLog(insertValue: InsertUserProfileAuditLog): Promise<UserProfileAuditLog> {
const [entry] = await db
.insert(userProfileAuditLog)
.values({ ...insertValue, createdAt: new Date() })
.returning();
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,
organizationId: number
): Promise<{
user: any;
tabs: UserProfileTab[];
fields: UserProfileField[];
fieldValues: UserProfileFieldValue[];
organizationalRoles: { id: number; name: string }[];
}> {
const user = await this.getUser(userId);
const tabs = await this.getUserProfileTabs(organizationId);
const fields = await this.getUserProfileFields(organizationId);
const fieldValues = user ? await this.getUserProfileFieldValues(userId) : [];
const userRoles = user
? await db
.select({ roleId: roleMembers.roleId, roleName: roles.name })
.from(roleMembers)
.innerJoin(roles, eq(roleMembers.roleId, roles.id))
.where(and(eq(roleMembers.userId, userId), eq(roles.organizationId, organizationId)))
: [];
const organizationalRoles = userRoles.map((r) => ({ id: r.roleId, name: r.roleName }));
let status = null;
if (user?.statusId) {
const [s] = await db.select().from(userStatuses).where(eq(userStatuses.id, user.statusId));
status = s || null;
}
return { user: { ...user, status }, tabs, fields, fieldValues, organizationalRoles };
}
}