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 { return await db .select() .from(userProfileTabs) .where(eq(userProfileTabs.organizationId, organizationId)) .orderBy(userProfileTabs.displayOrder); } async getUserProfileTabsByCreationOrder(organizationId: number): Promise { 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 { 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 { return await db .select() .from(userProfileFields) .where(eq(userProfileFields.organizationId, organizationId)) .orderBy(userProfileFields.displayOrder); } async getUserProfileFieldsByCreationOrder(organizationId: number): Promise { return await db .select() .from(userProfileFields) .where(eq(userProfileFields.organizationId, organizationId)) .orderBy(userProfileFields.id); } async getUserProfileFieldById(id: number, organizationId: number): Promise { 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 { 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 { 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 { 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`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 { 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 { return await db .select() .from(userProfileFieldValues) .where(eq(userProfileFieldValues.userId, userId)); } async getUserProfileFieldValue(userId: number, fieldId: number): Promise { 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 { 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 { 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 ): Promise { 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, organizationId: number, changedBy: number ): Promise { 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 { 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 { 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 { 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 { 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 }; } }