Files
iistwin/server/storage/user-profile.storage.ts
Ильяс Султанов a60a75eda0 feat(profile): read-first редизайн страницы пользователя, per-field как в задачах
- Раскладка: шапка-профиль → Основное/Контакты → Роль и доступ/Поля+Замещение → вторичные разделы чипами
- ProfileFieldRow: двойной клик/карандаш, редактор в слоте, Esc, optimistic-кэш, все типы полей
- Замещение: надпись + ⊕ popover, список под строкой
- Удалены RHF-форма, zod-схема и кнопки «Сохранить»
- server: PATCH /api/users/:id (частичное обновление, admin-градация, аудит)
- server: history-number в REST PATCH пишет историю + пересчёт средней
2026-09-06 18:47:05 +03:00

601 lines
22 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: переданное число идёт в историю и становится значением поля (последняя оценка);
// среднее за месяц пересчитывается отдельно — в поле, указанном в options.avgFieldCode (или в само поле)
let storedValue = value;
if (field.type === 'history-number') {
storedValue = await this.recordUserFieldHistoryValue(userId, field, value, organizationId, { taskId });
}
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 поля: insert в user_field_history
* и пересчёт месячной средней. Общая логика для автоматизаций и REST:
* автоматизации вызывают с taskId и без актора (аудит от «Автоматизация»),
* REST — с актором текущего пользователя. Возвращает распарсенное число.
* Нечисловое значение — ошибка.
*/
async recordUserFieldHistoryValue(
userId: number,
field: UserProfileField,
value: unknown,
organizationId: number,
options?: {
taskId?: number | null;
auditActor?: { changedBy: number | null; changedByName: string };
}
): Promise<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: options?.taskId ?? null,
});
await this.recomputeUserFieldMonthlyAverage(userId, field.id, organizationId, options?.auditActor);
return num;
}
/**
* Пересчитывает среднее за текущий календарный месяц по истории history-number поля.
* Среднее пишется в поле, указанное в options.avgFieldCode самого history-number поля
* (например «Оценка» → «Средняя оценка»); если avgFieldCode не задан — в само history-number поле.
* Если в месяце нет записей — целевое поле очищается (null). Возвращает новое среднее.
* auditActor — кто инициировал пересчёт (по умолчанию «Автоматизация»).
*/
async recomputeUserFieldMonthlyAverage(
userId: number,
fieldId: number,
organizationId: number,
auditActor?: { changedBy: number | null; changedByName: string }
) {
const [field] = await db.select().from(userProfileFields).where(and(
eq(userProfileFields.id, fieldId),
eq(userProfileFields.organizationId, organizationId),
));
if (!field) {
throw new Error(`Поле профиля ${fieldId} не найдено`);
}
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;
// Целевое поле для среднего: из options.avgFieldCode, иначе — само history-number поле
let targetFieldId = fieldId;
const avgFieldCode = (field.options as { avgFieldCode?: string } | null)?.avgFieldCode;
if (avgFieldCode) {
const target = await this.getUserProfileFieldByCode(avgFieldCode, organizationId);
if (!target) {
throw new Error(`Поле для среднего "${avgFieldCode}" (options.avgFieldCode поля "${field.name}") не найдено`);
}
targetFieldId = target.id;
}
const existing = await this.getUserProfileFieldValue(userId, targetFieldId);
if (existing) {
if (existing.value !== avg) {
await this.updateUserProfileFieldValue(userId, targetFieldId, { value: avg as UserProfileFieldValue['value'] });
await this.createUserProfileAuditLog({
userId,
organizationId,
action: 'field.changed',
fieldId: targetFieldId,
oldValue: existing.value ?? null,
newValue: avg != null ? String(avg) : null,
changedBy: auditActor?.changedBy ?? null,
changedByName: auditActor?.changedByName ?? 'Автоматизация',
metadata: { source: auditActor ? 'web' : 'automation', reason: 'monthly-average-recalc', historyFieldId: fieldId },
});
}
} else if (avg != null) {
await this.createUserProfileFieldValue({ userId, fieldId: targetFieldId, 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 };
}
}