- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
298 lines
13 KiB
TypeScript
298 lines
13 KiB
TypeScript
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 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: 'Ошибка при обновлении значения поля' });
|
|
}
|
|
}
|
|
);
|
|
}
|