Files
iistwin/server/storage/user-profile.storage.ts
Ильяс Султанов 6aececeb4a feat: автоматический пересчёт среднего для history-number в sandbox
- setFieldValue для history-number: переданное число пишется в историю, значением поля становится среднее за текущий месяц (автопересчёт)
- ctx.users.recalcFieldAverage(userId, fieldCodeOrId): принудительный пересчёт среднего за месяц (для schedule-автоматизаций; нет записей — поле очищается)
- документация обновлена (Automations.tsx, mcp.ts)
2026-09-04 20:09:27 +03:00

552 lines
20 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}" не найдено`);
}
// Для history-number: переданное число идёт в историю, значением поля становится среднее за текущий месяц
let storedValue = value;
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,
});
storedValue = await this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId);
}
const existing = await this.getUserProfileFieldValue(userId, field.id);
const saved = existing
? await this.updateUserProfileFieldValue(userId, field.id, { value: storedValue as UserProfileFieldValue['value'] })
: await this.createUserProfileFieldValue({ userId, fieldId: field.id, value: storedValue as InsertUserProfileFieldValue['value'] });
await this.createUserProfileAuditLog({
userId,
organizationId,
action: 'field.changed',
fieldId: field.id,
fieldName: field.name,
oldValue: existing?.value ?? null,
newValue: typeof storedValue === 'string' ? storedValue : storedValue != null ? JSON.stringify(storedValue) : null,
changedBy: null,
changedByName: 'Автоматизация',
metadata: { source: 'automation', taskId: taskId ?? null },
});
return saved;
}
/**
* Пересчитывает значение history-number поля как среднее за текущий календарный месяц.
* Если в месяце нет записей — поле очищается (null). Возвращает новое значение.
*/
async recomputeUserFieldMonthlyAverage(userId: number, fieldId: number, organizationId: number) {
const [row] = await db
.select({ avg: sql<number | null>`ROUND(AVG(${userFieldHistory.value})::numeric, 2)::float8` })
.from(userFieldHistory)
.where(and(
eq(userFieldHistory.userId, userId),
eq(userFieldHistory.fieldId, fieldId),
eq(userFieldHistory.organizationId, organizationId),
sql`${userFieldHistory.createdAt} >= date_trunc('month', now())`,
));
const avg = row?.avg != null ? Number(row.avg) : null;
const existing = await this.getUserProfileFieldValue(userId, fieldId);
if (existing) {
if (existing.value !== avg) {
await this.updateUserProfileFieldValue(userId, fieldId, { value: avg as UserProfileFieldValue['value'] });
await this.createUserProfileAuditLog({
userId,
organizationId,
action: 'field.changed',
fieldId,
oldValue: existing.value ?? null,
newValue: avg != null ? String(avg) : null,
changedBy: null,
changedByName: 'Автоматизация',
metadata: { source: 'automation', reason: 'monthly-average-recalc' },
});
}
} else if (avg != null) {
await this.createUserProfileFieldValue({ userId, fieldId, value: avg as InsertUserProfileFieldValue['value'] });
}
return avg;
}
/**
* ctx.users.recalcFieldAverage: принудительный пересчёт среднего за месяц для history-number поля.
*/
async recalcUserFieldAverageFromAutomation(userId: number, fieldCodeOrId: string | number, organizationId: number) {
const field = typeof fieldCodeOrId === 'number'
? await this.getUserProfileFieldById(fieldCodeOrId, organizationId)
: await this.getUserProfileFieldByCode(String(fieldCodeOrId), organizationId);
if (!field) {
throw new Error(`Поле профиля "${fieldCodeOrId}" не найдено`);
}
if (field.type !== 'history-number') {
throw new Error(`Пересчёт среднего доступен только для полей типа history-number, у поля "${field.name}" тип ${field.type}`);
}
const user = await this.getUser(userId);
if (!user || user.organizationId !== organizationId) {
throw new Error(`Пользователь ${userId} не найден в организации`);
}
return this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId);
}
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 };
}
}