Files
iistwin/server/routes/user-profile.routes.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

352 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 { Router } from "express";
import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { isValidFieldType } from "@shared/field-types";
import { canManageUser } from "../utils/user-access";
export function registerUserProfileRoutes(router: Router): void {
// === USER PROFILE TABS ===
router.get('/api/user-profile/tabs',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const tabs = await storage.getUserProfileTabs(req.organizationId!);
res.json({ success: true, tabs });
} catch (error) {
console.error('Get user profile tabs error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении вкладок профиля' });
}
}
);
router.post('/api/user-profile/tabs',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const { tabs } = req.body;
if (!Array.isArray(tabs)) {
return res.status(400).json({ success: false, error: 'Вкладки должны быть массивом' });
}
const savedTabs = await storage.bulkSaveUserProfileTabs(req.organizationId!, tabs);
res.json({ success: true, tabs: savedTabs, message: 'Вкладки сохранены' });
} catch (error) {
console.error('Save user profile tabs error:', error);
res.status(500).json({ success: false, error: 'Ошибка при сохранении вкладок профиля' });
}
}
);
// === USER PROFILE FIELDS ===
router.get('/api/user-profile/fields',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const fields = await storage.getUserProfileFields(req.organizationId!);
res.json({ success: true, fields });
} catch (error) {
console.error('Get user profile fields error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении полей профиля' });
}
}
);
router.post('/api/user-profile/fields',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const { fields } = req.body;
if (!Array.isArray(fields)) {
return res.status(400).json({ success: false, error: 'Поля должны быть массивом' });
}
const existingFields = await storage.getUserProfileFields(req.organizationId!);
const incomingIds = fields.filter((f: Record<string, unknown>) => f.id).map((f: Record<string, unknown>) => f.id as number);
const fieldsToDelete = existingFields.filter(existing => !incomingIds.includes(existing.id));
// Check if fields to delete are in use
if (fieldsToDelete.length > 0) {
const allUsers = await storage.getUsersByOrganization(req.organizationId!);
const usedFieldIds = new Set<number>();
for (const user of allUsers) {
const values = await storage.getUserProfileFieldValues(user.id);
for (const value of values) {
usedFieldIds.add(value.fieldId);
}
}
for (const field of fieldsToDelete) {
if (usedFieldIds.has(field.id)) {
return res.status(400).json({
success: false,
error: `Невозможно удалить поле "${field.name}" - оно используется в профилях пользователей. Сначала удалите значения этого поля во всех профилях.`
});
}
}
}
const fieldIdsToDelete = fieldsToDelete.map(f => f.id);
const normalizedFields = fields.map((f: Record<string, unknown>) => ({
...f,
maxFileCount: f.maxFileCount != null && parseInt(String(f.maxFileCount)) > 0 ? parseInt(String(f.maxFileCount)) : null,
maxFileSizeMB: f.maxFileSizeMB != null && parseInt(String(f.maxFileSizeMB)) > 0 ? parseInt(String(f.maxFileSizeMB)) : null,
}));
// Validate field types for new fields
for (const field of fields as Array<Record<string, unknown>>) {
const fieldId = field.id as number | undefined;
const isNew = !fieldId || (typeof fieldId === 'number' && fieldId < 0);
const fieldType = field.type as string;
if (isNew && fieldType && !isValidFieldType(fieldType, 'user')) {
return res.status(400).json({
success: false,
error: `Тип поля "${fieldType}" нельзя использовать в профиле пользователя.`,
});
}
}
const resultFields = await storage.bulkSaveUserProfileFields(
req.organizationId!,
fieldIdsToDelete,
normalizedFields as Parameters<typeof storage.bulkSaveUserProfileFields>[2]
);
res.status(201).json({ success: true, message: 'Поля сохранены', fields: resultFields });
} catch (error) {
console.error('Save user profile fields error:', error);
res.status(500).json({ success: false, error: 'Ошибка при сохранении полей профиля' });
}
}
);
// === USER PROFILE DETAIL ===
router.get('/api/users/:id/profile',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
}
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const detail = await storage.getUserProfileDetail(userId, req.organizationId!);
const canEdit = await canManageUser(req.user, userId, req.organizationId!) || req.user?.id === userId;
res.json({ success: true, ...detail, canEdit });
} catch (error) {
console.error('Get user profile detail error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении профиля пользователя' });
}
}
);
// === USER PROFILE AUDIT LOG ===
router.get('/api/users/:id/profile/audit-log',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
}
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const canView = await canManageUser(req.user, userId, req.organizationId!) || req.user?.id === userId;
if (!canView) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const auditLog = await storage.getUserProfileAuditLog(userId, req.organizationId!);
res.json({ success: true, auditLog });
} catch (error) {
console.error('Get user profile audit log error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении истории изменений' });
}
}
);
// === USER FIELD HISTORY (history-number) ===
const FIELD_HISTORY_PERIODS: Record<string, { days: number; bucket: 'day' | 'week' | 'month' }> = {
week: { days: 7, bucket: 'day' },
month: { days: 30, bucket: 'day' },
quarter: { days: 90, bucket: 'week' },
year: { days: 365, bucket: 'month' },
};
router.get('/api/users/:id/field-history/:fieldId',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
const fieldId = parseInt(req.params.fieldId);
if (isNaN(userId) || isNaN(fieldId)) {
return res.status(400).json({ success: false, error: 'Неверный ID' });
}
// Права — как у просмотра профиля: любой сотрудник той же организации
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const field = await storage.getUserProfileFieldById(fieldId, req.organizationId!);
if (!field || field.type !== 'history-number') {
return res.status(404).json({ success: false, error: 'Поле не найдено' });
}
const periodParam = String(req.query.period || 'month');
const period = FIELD_HISTORY_PERIODS[periodParam];
if (!period) {
return res.status(400).json({ success: false, error: 'Неверный период. Допустимо: week, month, quarter, year' });
}
const { points, overallAvg, totalCount } = await storage.getUserFieldHistoryPoints(
userId,
fieldId,
req.organizationId!,
period.days,
period.bucket
);
res.json({
success: true,
field: { id: field.id, name: field.name },
period: periodParam,
points,
overallAvg,
totalCount,
});
} catch (error) {
console.error('Get user field history error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении истории поля' });
}
}
);
// === USER PROFILE FIELD VALUES ===
router.get('/api/users/:id/field-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
}
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const values = await storage.getUserProfileFieldValues(userId);
res.json({ success: true, fieldValues: values });
} catch (error) {
console.error('Get user profile field values error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении значений полей' });
}
}
);
router.post('/api/users/:id/field-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
if (isNaN(userId)) {
return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
}
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const canEdit = await canManageUser(req.user, userId, req.organizationId!) || req.user?.id === userId;
if (!canEdit) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const { fieldValues } = req.body;
if (!Array.isArray(fieldValues)) {
return res.status(400).json({ success: false, error: 'Значения полей должны быть массивом' });
}
const changedBy = req.user!.id;
const createdValues = [];
for (const fv of fieldValues) {
const existing = await storage.getUserProfileFieldValue(userId, fv.fieldId);
if (fv.value === null || fv.value === undefined) {
if (existing) {
await storage.deleteUserProfileFieldValueWithAudit(userId, fv.fieldId, req.organizationId!, changedBy);
}
} else {
if (existing) {
const updated = await storage.updateUserProfileFieldValueWithAudit(userId, fv.fieldId, { value: fv.value }, req.organizationId!, changedBy);
createdValues.push(updated);
} else {
const created = await storage.createUserProfileFieldValueWithAudit({
userId,
fieldId: fv.fieldId,
value: fv.value,
}, req.organizationId!, changedBy);
createdValues.push(created);
}
}
}
res.json({ success: true, fieldValues: createdValues });
} catch (error) {
console.error('Save user profile field values error:', error);
res.status(500).json({ success: false, error: 'Ошибка при сохранении значений полей' });
}
}
);
router.patch('/api/users/:id/field-values/:fieldId',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
const fieldId = parseInt(req.params.fieldId);
if (isNaN(userId) || isNaN(fieldId)) {
return res.status(400).json({ success: false, error: 'Неверный ID' });
}
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const canEdit = await canManageUser(req.user, userId, req.organizationId!) || req.user?.id === userId;
if (!canEdit) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const changedBy = req.user!.id;
const { value } = req.body;
const existing = await storage.getUserProfileFieldValue(userId, fieldId);
if (value === null || value === undefined) {
if (existing) {
await storage.deleteUserProfileFieldValueWithAudit(userId, fieldId, req.organizationId!, changedBy);
}
res.json({ success: true, fieldValue: null });
} else {
if (existing) {
const updated = await storage.updateUserProfileFieldValueWithAudit(userId, fieldId, { value }, req.organizationId!, changedBy);
res.json({ success: true, fieldValue: updated });
} else {
const created = await storage.createUserProfileFieldValueWithAudit({ userId, fieldId, value }, req.organizationId!, changedBy);
res.json({ success: true, fieldValue: created });
}
}
} catch (error) {
console.error('Patch user profile field value error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении значения поля' });
}
}
);
}