- setFieldValue для history-number: переданное число пишется в историю, значением поля становится среднее за текущий месяц (автопересчёт) - ctx.users.recalcFieldAverage(userId, fieldCodeOrId): принудительный пересчёт среднего за месяц (для schedule-автоматизаций; нет записей — поле очищается) - документация обновлена (Automations.tsx, mcp.ts)
552 lines
20 KiB
TypeScript
552 lines
20 KiB
TypeScript
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 };
|
||
}
|
||
}
|