fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,596 @@
import { Router } from "express";
import { z } from 'zod';
import { storage } from "../storage";
import { authService } from "../services/auth.service";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import {
createUserCustomFieldSchema, updateUserCustomFieldSchema,
setUserCustomValueSchema,
} from "@shared/schema";
export function registerUserManagementRoutes(router: Router): void {
// === User Custom Fields routes ===
router.get('/api/user-custom-fields',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const fields = await storage.getUserCustomFields(req.organizationId!);
res.json({ success: true, fields });
} catch (error) {
console.error('Get user custom fields error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.post('/api/user-custom-fields',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
validateRequest(createUserCustomFieldSchema),
async (req: AuthenticatedRequest, res) => {
try {
const existingFields = await storage.getUserCustomFields(req.organizationId!);
if (existingFields.some(f => f.code === req.body.code)) {
return res.status(400).json({ success: false, error: 'Поле с таким кодом уже существует' });
}
const field = await storage.createUserCustomField(req.organizationId!, req.body);
res.status(201).json({ success: true, field });
} catch (error) {
console.error('Create user custom field error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.patch('/api/user-custom-fields/:id',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
validateRequest(updateUserCustomFieldSchema),
async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
const existing = await storage.getUserCustomField(id, req.organizationId!);
if (!existing) {
return res.status(404).json({ success: false, error: 'Поле не найдено' });
}
if (req.body.code && req.body.code !== existing.code) {
const allFields = await storage.getUserCustomFields(req.organizationId!);
if (allFields.some(f => f.code === req.body.code && f.id !== id)) {
return res.status(400).json({ success: false, error: 'Поле с таким кодом уже существует' });
}
}
const field = await storage.updateUserCustomField(id, req.organizationId!, req.body);
res.json({ success: true, field });
} catch (error) {
console.error('Update user custom field error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.delete('/api/user-custom-fields/:id',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
await storage.deleteUserCustomField(id, req.organizationId!);
res.json({ success: true, message: 'Поле удалено' });
} catch (error) {
console.error('Delete user custom field error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
router.post('/api/user-custom-fields/reorder',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const { fieldIds } = req.body;
if (!Array.isArray(fieldIds)) {
return res.status(400).json({ success: false, error: 'fieldIds должен быть массивом' });
}
await storage.reorderUserCustomFields(req.organizationId!, fieldIds);
res.json({ success: true });
} catch (error) {
console.error('Reorder user custom fields error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// Get custom values for a user
router.get('/api/users/:userId/custom-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const appRole = req.user?.appRole;
if (appRole !== 'admin' && req.user?.id !== userId) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const values = await storage.getUserCustomValues(userId, req.organizationId!);
res.json({ success: true, values });
} catch (error) {
console.error('Get user custom values error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// Set custom values for a user (admin only, or self)
router.put('/api/users/:userId/custom-values',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
const targetUser = await storage.getUser(userId);
if (!targetUser || targetUser.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const appRole = req.user?.appRole;
if (appRole !== 'admin' && req.user?.id !== userId) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
const { values } = req.body;
if (!Array.isArray(values)) {
return res.status(400).json({ success: false, error: 'values должен быть массивом' });
}
await storage.setUserCustomValues(userId, values, req.organizationId!);
const updatedValues = await storage.getUserCustomValues(userId, req.organizationId!);
res.json({ success: true, values: updatedValues });
} catch (error) {
console.error('Set user custom values error:', error);
res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
}
);
// === User management routes ===
// Public list endpoint — accessible to all org members (needed for chats, assignments, etc.)
router.get('/api/users/list',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const users = await storage.getUsersByOrganization(req.organizationId!);
const orgRoles = await storage.getRoles(req.organizationId!);
let filtersObj: Record<string, string> = {};
try { filtersObj = req.query.filters ? JSON.parse(req.query.filters as string) : {}; } catch {}
const sortColumn = (req.query.sortColumn as string) || '';
const sortDirection = (req.query.sortDirection as string) === 'desc' ? 'desc' : 'asc';
// Build role membership map for all users
const roleMembersMap = new Map<number, Array<{ id: number; name: string }>>();
for (const r of orgRoles) {
for (const m of r.members) {
if (!roleMembersMap.has(m.userId)) roleMembersMap.set(m.userId, []);
roleMembersMap.get(m.userId)!.push({ id: r.id, name: r.name });
}
}
const getUserColVal = (u: any, col: string): string => {
if (col === 'name') return `${u.firstName} ${u.middleName || ''} ${u.lastName}`.trim();
if (col === 'email') return u.email || '';
if (col === 'role') return u.appRole || '';
if (col === 'status') return u.isActive ? 'Активен' : 'Неактивен';
if (col === 'position') return u.position || '';
return '';
};
let safeUsers = users.map(user => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
middleName: user.middleName,
position: user.position,
appRole: user.appRole,
isActive: user.isActive,
emailVerified: user.emailVerified,
lastLogin: user.lastLogin,
lastActivityAt: user.lastActivityAt,
createdAt: user.createdAt,
organizationalRoles: roleMembersMap.get(user.id) || [],
}));
Object.entries(filtersObj).forEach(([col, val]) => {
if (!val) return;
const valLower = (val as string).toLowerCase();
safeUsers = safeUsers.filter(u => getUserColVal(u, col).toLowerCase().includes(valLower));
});
if (sortColumn) {
safeUsers = safeUsers.sort((a, b) => {
const av = getUserColVal(a, sortColumn).toLowerCase();
const bv = getUserColVal(b, sortColumn).toLowerCase();
const cmp = av.localeCompare(bv, 'ru');
return sortDirection === 'asc' ? cmp : -cmp;
});
}
res.json({ success: true, users: safeUsers });
} catch (error) {
console.error('Get users list error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении пользователей' });
}
}
);
// Admin-only user list (kept for backward compatibility with admin integrations)
router.get('/api/users',
authenticateToken,
tenantIsolation,
requirePermission('users.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const users = await storage.getUsersByOrganization(req.organizationId!);
const orgRoles = await storage.getRoles(req.organizationId!);
let filtersObj: Record<string, string> = {};
try { filtersObj = req.query.filters ? JSON.parse(req.query.filters as string) : {}; } catch {}
const sortColumn = (req.query.sortColumn as string) || '';
const sortDirection = (req.query.sortDirection as string) === 'desc' ? 'desc' : 'asc';
// Build role membership map for all users
const roleMembersMap = new Map<number, Array<{ id: number; name: string }>>();
for (const r of orgRoles) {
for (const m of r.members) {
if (!roleMembersMap.has(m.userId)) roleMembersMap.set(m.userId, []);
roleMembersMap.get(m.userId)!.push({ id: r.id, name: r.name });
}
}
const getUserColVal = (u: any, col: string): string => {
if (col === 'name') return `${u.firstName} ${u.middleName || ''} ${u.lastName}`.trim();
if (col === 'email') return u.email || '';
if (col === 'role') return u.appRole || '';
if (col === 'status') return u.isActive ? 'Активен' : 'Неактивен';
if (col === 'position') return u.position || '';
return '';
};
let safeUsers = users.map(user => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
middleName: user.middleName,
position: user.position,
appRole: user.appRole,
isActive: user.isActive,
emailVerified: user.emailVerified,
lastLogin: user.lastLogin,
lastActivityAt: user.lastActivityAt,
createdAt: user.createdAt,
organizationalRoles: roleMembersMap.get(user.id) || [],
}));
Object.entries(filtersObj).forEach(([col, val]) => {
if (!val) return;
const valLower = (val as string).toLowerCase();
safeUsers = safeUsers.filter(u => getUserColVal(u, col).toLowerCase().includes(valLower));
});
if (sortColumn) {
safeUsers = safeUsers.sort((a, b) => {
const av = getUserColVal(a, sortColumn).toLowerCase();
const bv = getUserColVal(b, sortColumn).toLowerCase();
const cmp = av.localeCompare(bv, 'ru');
return sortDirection === 'asc' ? cmp : -cmp;
});
}
res.json({ success: true, users: safeUsers });
} catch (error) {
console.error('Get users error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении пользователей' });
}
});
// Column values for users (autocomplete for filters)
router.get('/api/users/column-values',
async (req: AuthenticatedRequest, res) => {
try {
const column = (req.query.column as string) || '';
const search = ((req.query.search as string) || '').toLowerCase();
let context: Record<string, string> = {};
try { context = req.query.context ? JSON.parse(req.query.context as string) : {}; } catch {}
let users = await storage.getUsersByOrganization(req.organizationId!);
const getUserColVal = (u: any, col: string): string => {
if (col === 'name') return `${u.firstName} ${u.middleName || ''} ${u.lastName}`.trim();
if (col === 'email') return u.email || '';
if (col === 'role') return u.appRole || '';
if (col === 'status') return u.isActive ? 'Активен' : 'Неактивен';
if (col === 'position') return u.position || '';
return '';
};
Object.entries(context).forEach(([col, val]) => {
if (!val) return;
const vl = (val as string).toLowerCase();
users = users.filter(u => getUserColVal(u, col).toLowerCase().includes(vl));
});
const seen = new Set<string>();
const values: string[] = [];
for (const u of users) {
const val = getUserColVal(u, column);
if (!val || seen.has(val)) continue;
if (search && !val.toLowerCase().includes(search)) continue;
seen.add(val);
values.push(val);
if (values.length >= 20) break;
}
res.json({ values });
} catch (error) {
res.status(500).json({ error: 'Ошибка получения значений' });
}
}
);
router.post('/api/users',
requirePermission('users.manage'),
validateRequest(z.object({
email: z.string().email('Неверный формат email'),
firstName: z.string().min(1, 'Имя обязательно'),
lastName: z.string().min(1, 'Фамилия обязательна'),
middleName: z.string().optional(),
position: z.string().optional(),
appRole: z.string().min(1, 'Выберите роль доступа'),
organizationalRoleIds: z.array(z.number()).optional(),
sendInvite: z.boolean().default(true),
statusId: z.union([z.number(), z.null()]).optional()
})),
async (req: AuthenticatedRequest, res) => {
try {
const result = await authService.createUser(req.organizationId!, {
email: req.body.email,
firstName: req.body.firstName,
lastName: req.body.lastName,
middleName: req.body.middleName,
position: req.body.position,
appRole: req.body.appRole,
organizationalRoleIds: req.body.organizationalRoleIds,
sendInvite: req.body.sendInvite,
statusId: req.body.statusId ?? undefined
});
if (result.success && result.user) {
res.status(201).json({
success: true,
message: result.message,
user: {
id: result.user.id,
email: result.user.email,
firstName: result.user.firstName,
lastName: result.user.lastName,
middleName: result.user.middleName,
position: result.user.position,
appRole: result.user.appRole,
isActive: result.user.isActive,
emailVerified: result.user.emailVerified,
createdAt: result.user.createdAt
}
});
} else {
res.status(400).json({ success: false, error: result.error });
}
} catch (error) {
console.error('Create user error:', error);
res.status(500).json({ success: false, error: 'Ошибка при создании пользователя' });
}
}
);
// Search users for @mentions (must be declared before /:id route)
router.get('/api/users/search',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const { q: query, limit = '10' } = req.query;
if (!query || typeof query !== 'string' || query.trim().length === 0) {
return res.status(400).json({ success: false, error: 'Поисковый запрос обязателен' });
}
if (!req.organizationId || typeof req.organizationId !== 'number' || isNaN(req.organizationId)) {
console.error('Invalid organizationId in search users:', req.organizationId);
return res.status(400).json({ success: false, error: 'Ошибка идентификации организации' });
}
const searchLimit = Math.min(parseInt(limit as string) || 10, 20);
const users = await storage.searchUsers(query.trim(), req.organizationId, searchLimit);
res.json({
success: true,
users: users.map(user => ({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
position: user.position
}))
});
} catch (error) {
console.error('Search users error:', error);
res.status(500).json({ success: false, error: 'Ошибка при поиске пользователей' });
}
}
);
// Unified search for @mentions - returns both users and bots
router.get('/api/mentions/search',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const { q: query, limit = '10' } = req.query;
if (!query || typeof query !== 'string' || query.trim().length === 0) {
return res.status(400).json({ success: false, error: 'Поисковый запрос обязателен' });
}
if (!req.organizationId || typeof req.organizationId !== 'number') {
return res.status(400).json({ success: false, error: 'Ошибка идентификации организации' });
}
const searchLimit = Math.min(parseInt(limit as string) || 10, 20);
const [users, allBots] = await Promise.all([
storage.searchUsers(query.trim(), req.organizationId, searchLimit),
storage.searchBots(query.trim(), req.organizationId, searchLimit)
]);
const currentUser = req.user!;
const bots = allBots.filter(bot => {
const policy = bot.accessPolicy;
if (!policy || policy.mode === 'all') return true;
if (policy.mode === 'roles') {
const roles = policy.roles ?? [];
if (roles.length === 0) return false;
return roles.includes(currentUser.appRole ?? '');
}
if (policy.mode === 'users') {
const userIds = policy.userIds ?? [];
if (userIds.length === 0) return false;
return userIds.includes(currentUser.id);
}
return false;
});
const suggestions = [
...users.map(user => ({
id: user.id,
type: 'user' as const,
name: `${user.firstName} ${user.middleName || ''} ${user.lastName}`.trim(),
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
position: user.position
})),
...bots.map(bot => ({
id: bot.id,
type: 'bot' as const,
name: bot.name,
firstName: bot.name,
lastName: '',
avatarUrl: bot.avatarUrl
}))
].slice(0, searchLimit);
res.json({ success: true, suggestions });
} catch (error) {
console.error('Search mentions error:', error);
res.status(500).json({ success: false, error: 'Ошибка при поиске' });
}
}
);
// === User offline subscriptions ===
const subscriptionSchema = z.object({
formId: z.number().int().positive(),
isActive: z.boolean().optional(),
includeRelatedTasks: z.boolean().optional(),
includeFiles: z.boolean().optional(),
});
async function canManageUserSubscriptions(req: AuthenticatedRequest, targetUserId: number): Promise<boolean> {
if (req.user!.id === targetUserId) return true;
return storage.hasAppRolePermission(req.user!.appRole, ['users.manage']);
}
router.get('/api/users/:userId/offline-subscriptions',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
if (isNaN(userId)) return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
if (!(await canManageUserSubscriptions(req, userId))) {
return res.status(403).json({ success: false, error: 'Нет прав на просмотр подписок' });
}
const subscriptions = await storage.getUserOfflineSubscriptions(userId, req.organizationId!);
res.json({ success: true, subscriptions });
} catch (error: any) {
console.error('Get offline subscriptions error:', error);
res.status(500).json({ success: false, error: error.message || 'Ошибка загрузки подписок' });
}
}
);
router.post('/api/users/:userId/offline-subscriptions',
authenticateToken,
tenantIsolation,
validateRequest(subscriptionSchema),
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
if (isNaN(userId)) return res.status(400).json({ success: false, error: 'Неверный ID пользователя' });
if (!(await canManageUserSubscriptions(req, userId))) {
return res.status(403).json({ success: false, error: 'Нет прав на управление подписками' });
}
const { formId, isActive, includeRelatedTasks, includeFiles } = req.body;
const existing = await storage.getUserOfflineSubscription(userId, formId);
const subscription = await storage.upsertUserOfflineSubscription({
organizationId: req.organizationId!,
userId,
formId,
isActive: isActive ?? existing?.isActive ?? true,
includeRelatedTasks: includeRelatedTasks ?? existing?.includeRelatedTasks ?? true,
includeFiles: includeFiles ?? existing?.includeFiles ?? false,
});
res.json({ success: true, subscription });
} catch (error: any) {
console.error('Upsert offline subscription error:', error);
res.status(500).json({ success: false, error: error.message || 'Ошибка сохранения подписки' });
}
}
);
router.delete('/api/users/:userId/offline-subscriptions/:formId',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.userId);
const formId = parseInt(req.params.formId);
if (isNaN(userId) || isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверные параметры' });
}
if (!(await canManageUserSubscriptions(req, userId))) {
return res.status(403).json({ success: false, error: 'Нет прав на управление подписками' });
}
await storage.deleteUserOfflineSubscription(userId, formId);
res.json({ success: true });
} catch (error: any) {
console.error('Delete offline subscription error:', error);
res.status(500).json({ success: false, error: error.message || 'Ошибка удаления подписки' });
}
}
);
}