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

396 lines
18 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 {
// Поле профиля с options.readOnly менять через API нельзя — только из автоматизаций (storage напрямую)
async function assertProfileFieldEditable(fieldId: number, organizationId: number): Promise<string | null> {
const field = await storage.getUserProfileFieldById(fieldId, organizationId);
if (!field) return `Поле профиля ${fieldId} не найдено`;
if ((field.options as { readOnly?: boolean } | null)?.readOnly) {
return `Поле «${field.name}» доступно только для чтения`;
}
return null;
}
// === 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 readOnlyError = await assertProfileFieldEditable(fv.fieldId, req.organizationId!);
if (readOnlyError) {
return res.status(400).json({ success: false, error: readOnlyError });
}
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 readOnlyError = await assertProfileFieldEditable(fieldId, req.organizationId!);
if (readOnlyError) {
return res.status(400).json({ success: false, error: readOnlyError });
}
const field = await storage.getUserProfileFieldById(fieldId, req.organizationId!);
const existing = await storage.getUserProfileFieldValue(userId, fieldId);
if (value === null || value === undefined) {
// Удаление (null): очищается только значение поля, история history-number не трогается
if (existing) {
await storage.deleteUserProfileFieldValueWithAudit(userId, fieldId, req.organizationId!, changedBy);
}
res.json({ success: true, fieldValue: null });
} else if (field!.type === 'history-number') {
// history-number: число пишется в user_field_history (актор — текущий пользователь),
// становится значением поля, месячная средняя пересчитывается
let num: number;
try {
num = await storage.recordUserFieldHistoryValue(userId, field!, value, req.organizationId!, {
auditActor: {
changedBy,
changedByName: `${req.user!.firstName || ''} ${req.user!.lastName || ''}`.trim(),
},
});
} catch (err) {
return res.status(400).json({
success: false,
error: err instanceof Error ? err.message : 'Значение должно быть числом',
});
}
if (existing) {
const updated = await storage.updateUserProfileFieldValueWithAudit(userId, fieldId, { value: num }, req.organizationId!, changedBy);
res.json({ success: true, fieldValue: updated });
} else {
const created = await storage.createUserProfileFieldValueWithAudit({ userId, fieldId, value: num }, req.organizationId!, changedBy);
res.json({ success: true, fieldValue: created });
}
} 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: 'Ошибка при обновлении значения поля' });
}
}
);
}