Files
iistwin/server/routes/user.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

657 lines
25 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 { z } from 'zod';
import { storage } from "../storage";
import { userStatusesStorage } from "../storage/user-statuses.storage";
import { requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { validateTenantAccess } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { hashPassword } from "../utils/password";
import { logAudit, getClientIp } from "../utils/audit";
import { canManageUser, filterUsersByAccess, getManagedUserIds } from "../utils/user-access";
import { db } from "../db";
import { roles, roleMembers, userStatuses, insertUserStatusSchema, updateUserStatusSchema } from "@shared/schema";
import { eq, and, inArray } from "drizzle-orm";
export function registerUserRoutes(router: ReturnType<typeof import("express").Router>): void {
router.get('/api/users/:id(\\d+)', validateTenantAccess, async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
if (!Number.isFinite(userId)) {
return res.status(400).json({
success: false,
error: 'Некорректный ID пользователя'
});
}
const user = await storage.getUser(userId);
if (!user || user.organizationId !== req.organizationId) {
return res.status(404).json({
success: false,
error: 'Пользователь не найден'
});
}
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
res.json({
success: true,
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
middleName: user.middleName,
position: user.position,
phone: user.phone,
appRole: user.appRole,
isActive: user.isActive,
emailVerified: user.emailVerified,
statusId: user.statusId,
lastLogin: user.lastLogin,
createdAt: user.createdAt,
canEdit
}
});
} catch (error) {
console.error('Get user error:', error);
res.status(500).json({
success: false,
error: 'Ошибка при получении пользователя'
});
}
});
async function handleListUsers(req: AuthenticatedRequest, res: import("express").Response) {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 25));
let search = typeof req.query.search === 'string' ? req.query.search.trim() : undefined;
let roleId = req.query.roleId ? parseInt(req.query.roleId as string) : undefined;
let appRole: string | undefined = typeof req.query.appRole === 'string' ? req.query.appRole : undefined;
let isActive: boolean | undefined = req.query.isActive === 'true' ? true : req.query.isActive === 'false' ? false : undefined;
let sortBy: 'name' | 'createdAt' | 'lastLogin' = (req.query.sortBy as 'name' | 'createdAt' | 'lastLogin') || 'name';
let order: 'asc' | 'desc' = (req.query.order as 'asc' | 'desc') || 'asc';
// Обратная совместимость со старым форматом фильтров Users.tsx
const filtersStr = typeof req.query.filters === 'string' ? req.query.filters : '';
if (filtersStr) {
try {
const filters = JSON.parse(filtersStr) as Record<string, string>;
if (filters.name && !search) search = filters.name;
if (filters.email && !search) search = filters.email;
if (filters.position && !search) search = filters.position;
if (filters.role) {
appRole = filters.role === 'Администратор' ? 'admin' : filters.role === 'Пользователь' ? 'user' : undefined;
}
if (filters.status) {
isActive = filters.status === 'Активен' ? true : filters.status === 'Неактивен' ? false : undefined;
}
} catch { /* ignore */ }
}
const sortColumn = typeof req.query.sortColumn === 'string' ? req.query.sortColumn : undefined;
const sortDirection = typeof req.query.sortDirection === 'string' ? req.query.sortDirection : undefined;
if (sortColumn === 'name') sortBy = 'name';
if (sortColumn === 'createdAt') sortBy = 'createdAt';
if (sortColumn === 'lastLogin') sortBy = 'lastLogin';
if (sortDirection === 'asc' || sortDirection === 'desc') order = sortDirection;
const isAdmin = req.user?.appRole === 'admin';
const managedIds = isAdmin ? null : await getManagedUserIds(req.user!.id, req.organizationId!);
const result = await storage.listUsers(req.organizationId!, {
search,
roleId,
appRole,
isActive,
sortBy,
order,
limit: pageSize,
offset: (page - 1) * pageSize,
});
let filteredUsers = result.users;
if (managedIds !== null) {
filteredUsers = result.users.filter(u => managedIds.has(u.id) || u.id === req.user!.id);
}
const userIds = filteredUsers.map(u => u.id);
const userRoles = userIds.length > 0
? await db
.select({ userId: roleMembers.userId, roleId: roleMembers.roleId, roleName: roles.name })
.from(roleMembers)
.innerJoin(roles, eq(roleMembers.roleId, roles.id))
.where(and(inArray(roleMembers.userId, userIds), eq(roles.organizationId, req.organizationId!)))
: [];
const rolesByUser = new Map<number, { id: number; name: string }[]>();
for (const ur of userRoles) {
if (!rolesByUser.has(ur.userId)) rolesByUser.set(ur.userId, []);
rolesByUser.get(ur.userId)!.push({ id: ur.roleId, name: ur.roleName });
}
const allStatuses = await userStatusesStorage.listByOrganization(req.organizationId!);
const statusById = new Map(allStatuses.map(s => [s.id, s]));
res.json({
success: true,
users: filteredUsers.map(u => {
const status = u.statusId ? statusById.get(u.statusId) : undefined;
return {
id: u.id,
email: u.email,
firstName: u.firstName,
lastName: u.lastName,
middleName: u.middleName,
position: u.position,
phone: u.phone,
appRole: u.appRole,
isActive: u.isActive,
emailVerified: u.emailVerified,
statusId: u.statusId,
status: status ? { id: status.id, name: status.name, color: status.color } : null,
lastLogin: u.lastLogin,
createdAt: u.createdAt,
organizationalRoles: rolesByUser.get(u.id) || [],
};
}),
total: managedIds === null ? result.total : filteredUsers.length,
page,
pageSize,
});
}
router.get('/api/users', validateTenantAccess, async (req: AuthenticatedRequest, res) => {
try {
await handleListUsers(req, res);
} catch (error) {
console.error('List users error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении списка пользователей' });
}
});
router.get('/api/users/list', validateTenantAccess, async (req: AuthenticatedRequest, res) => {
try {
await handleListUsers(req, res);
} catch (error) {
console.error('List users error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении списка пользователей' });
}
});
router.get('/api/users/column-values', validateTenantAccess, async (req: AuthenticatedRequest, res) => {
try {
const column = typeof req.query.column === 'string' ? req.query.column : '';
const search = typeof req.query.search === 'string' ? req.query.search.toLowerCase() : '';
const isAdmin = req.user?.appRole === 'admin';
const managedIds = isAdmin ? null : await getManagedUserIds(req.user!.id, req.organizationId!);
const allUsers = await storage.getUsersByOrganization(req.organizationId!);
let users = managedIds === null ? allUsers : allUsers.filter(u => managedIds.has(u.id) || u.id === req.user!.id);
let values: string[] = [];
switch (column) {
case 'name':
values = users.map(u => `${u.lastName} ${u.firstName} ${u.middleName || ''}`.trim());
break;
case 'email':
values = users.map(u => u.email);
break;
case 'role':
values = users.map(u => u.appRole === 'admin' ? 'Администратор' : 'Пользователь');
break;
case 'status':
values = users.map(u => u.isActive ? 'Активен' : 'Неактивен');
break;
case 'position':
values = users.map(u => u.position || '').filter(Boolean);
break;
default:
values = [];
}
const unique = Array.from(new Set(values)).filter(v => v.toLowerCase().includes(search)).slice(0, 50);
res.json({ success: true, values: unique });
} catch (error) {
console.error('Get user column values error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении значений колонки' });
}
});
router.put('/api/users/:id(\\d+)',
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
if (!Number.isFinite(userId)) {
return res.status(400).json({
success: false,
error: 'Некорректный ID пользователя'
});
}
const user = await storage.getUser(userId);
if (!user || user.organizationId !== req.organizationId) {
return res.status(404).json({
success: false,
error: 'Пользователь не найден'
});
}
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
if (!canEdit) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
// Только админ может менять appRole, isActive и организационные роли
const isAdmin = req.user?.appRole === 'admin';
const updates: Partial<typeof user> = {
firstName: req.body.firstName,
lastName: req.body.lastName,
middleName: req.body.middleName,
position: req.body.position,
phone: req.body.phone,
additionalPhones: req.body.additionalPhones,
additionalEmails: req.body.additionalEmails,
};
const oldRole = user.appRole;
if (isAdmin) {
updates.appRole = req.body.appRole;
updates.isActive = req.body.isActive;
updates.statusId = req.body.statusId;
}
const updatedUser = await storage.updateUser(userId, updates);
// Audit log for system profile fields
const auditFields = [
{ name: 'Телефон', old: user.phone, new: updates.phone },
{ name: 'Дополнительные телефоны', old: user.additionalPhones, new: updates.additionalPhones },
{ name: 'Дополнительные email', old: user.additionalEmails, new: updates.additionalEmails },
];
for (const auditField of auditFields) {
if (JSON.stringify(auditField.old ?? null) !== JSON.stringify(auditField.new ?? null)) {
await storage.createUserProfileAuditLog({
userId,
organizationId: req.organizationId!,
action: 'field_value.updated',
fieldName: auditField.name,
oldValue: auditField.old ?? null,
newValue: auditField.new ?? null,
changedBy: req.user!.id,
changedByName: req.user ? `${req.user.firstName || ''} ${req.user.lastName || ''}`.trim() : undefined,
});
}
}
if (isAdmin && req.body.appRole !== undefined && req.body.appRole !== oldRole) {
logAudit({
action: 'user.role.changed',
userId: req.user!.id,
organizationId: req.organizationId!,
details: {
targetUserId: userId,
oldRole,
newRole: req.body.appRole,
},
ip: getClientIp(req),
userAgent: req.headers['user-agent'] ?? null,
});
}
// Update organizational roles if provided (only admin)
if (isAdmin && req.body.organizationalRoleIds !== undefined) {
await storage.setUserOrganizationalRoles(userId, req.body.organizationalRoleIds);
}
res.json({
success: true,
message: 'Пользователь обновлен',
user: {
id: updatedUser.id,
email: updatedUser.email,
firstName: updatedUser.firstName,
lastName: updatedUser.lastName,
middleName: updatedUser.middleName,
position: updatedUser.position,
phone: updatedUser.phone,
appRole: updatedUser.appRole,
isActive: updatedUser.isActive,
emailVerified: updatedUser.emailVerified,
lastLogin: updatedUser.lastLogin,
createdAt: updatedUser.createdAt
}
});
} catch (error) {
console.error('Update user error:', error);
res.status(500).json({
success: false,
error: 'Ошибка при обновлении пользователя'
});
}
}
);
// Change user password endpoint for admins
router.put('/api/users/:id/password',
requirePermission('users.manage'),
validateTenantAccess,
validateRequest(z.object({
password: z.string().min(1, 'Пароль обязателен')
})),
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
const user = await storage.getUser(userId);
if (!user || user.organizationId !== req.organizationId) {
return res.status(404).json({
success: false,
error: 'Пользователь не найден'
});
}
// Hash the new password with validation
const passwordHash = await hashPassword(req.body.password);
// Update user password
await storage.updateUser(userId, {
passwordHash,
updatedAt: new Date()
});
// Revoke all existing sessions for the user to force re-login
await storage.deleteUserSessions(userId);
res.json({
success: true,
message: 'Пароль пользователя изменен'
});
} catch (error) {
console.error('Change password error:', error);
// Check if it's a password validation error
if (error instanceof Error && error.message.includes('Пароль должен')) {
return res.status(400).json({
success: false,
error: error.message
});
}
res.status(500).json({
success: false,
error: 'Ошибка при изменении пароля'
});
}
}
);
router.delete('/api/users/:id',
requirePermission('users.manage'),
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = parseInt(req.params.id);
const user = await storage.getUser(userId);
if (!user || user.organizationId !== req.organizationId) {
return res.status(404).json({
success: false,
error: 'Пользователь не найден'
});
}
// Don't allow deleting yourself
if (userId === req.user.id) {
return res.status(400).json({
success: false,
error: 'Нельзя удалить самого себя'
});
}
await storage.deleteUser(userId);
res.json({
success: true,
message: 'Пользователь удален'
});
} catch (error) {
console.error('Delete user error:', error);
res.status(500).json({
success: false,
error: 'Ошибка при удалении пользователя'
});
}
}
);
// Organization stats
router.get('/api/organizations/stats', async (req: AuthenticatedRequest, res) => {
try {
const users = await storage.getUsersByOrganization(req.organizationId!);
const stats = {
totalUsers: users.length,
activeUsers: users.filter(u => u.isActive).length,
adminUsers: users.filter(u => u.appRole === 'admin').length,
inactiveUsers: users.filter(u => !u.isActive).length,
unverifiedUsers: users.filter(u => !u.emailVerified).length
};
res.json({
success: true,
stats
});
} catch (error) {
console.error('Get stats error:', error);
res.status(500).json({
success: false,
error: 'Ошибка при получении статистики'
});
}
});
// Get organizational roles for a user
router.get('/api/users/:id/roles',
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
const user = await storage.getUser(userId);
if (!user || user.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const roles = await storage.getUserOrganizationalRoles(userId, req.organizationId!);
res.json({ success: true, roles });
} catch (error) {
console.error('Get user roles error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении ролей' });
}
}
);
// Update organizational roles for a user
router.put('/api/users/:id/roles',
requirePermission('users.manage'),
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
const user = await storage.getUser(userId);
if (!user || user.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const roleIds = Array.isArray(req.body.roleIds) ? req.body.roleIds.map(Number) : [];
await storage.setUserOrganizationalRoles(userId, roleIds);
res.json({ success: true });
} catch (error) {
console.error('Set user roles error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении ролей' });
}
}
);
// Linked tasks for user profile dossier
router.get('/api/users/:id(\\d+)/linked-tasks',
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
if (!Number.isFinite(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 linked = await storage.getLinkedTasksForUser(userId, req.organizationId!);
const appRole = req.user!.appRole;
const accessibleIds = await storage.getAccessibleTaskIds(req.user!.id, req.organizationId!, appRole);
const filtered = accessibleIds !== null
? linked.filter((item) => accessibleIds.has(item.task.id))
: linked;
res.json({
success: true,
tasks: filtered.map((item) => ({
id: item.task.id,
title: item.task.title,
formId: item.form.id,
formName: item.form.name,
statusId: item.status?.id,
statusName: item.status?.name,
statusColor: item.status?.color,
isCompleted: item.task.isCompleted,
dueDate: item.task.dueDate,
createdAt: item.task.createdAt,
updatedAt: item.task.updatedAt,
fieldId: item.field.id,
fieldName: item.field.name,
})),
});
} catch (error) {
console.error('Get linked tasks error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении связанных задач' });
}
}
);
// Get application roles (system roles)
router.get('/api/app-roles', async (_req, res) => {
try {
const { appRoles: arTable } = await import('@shared/schema');
const { db } = await import('../db');
const rows = await db.select().from(arTable).orderBy(arTable.name);
res.json({ success: true, appRoles: rows });
} catch (error) {
console.error('Get app roles error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении ролей' });
}
});
// --- User statuses ---
router.get('/api/user-statuses', validateTenantAccess, async (req: AuthenticatedRequest, res) => {
try {
const statuses = await userStatusesStorage.listByOrganization(req.organizationId!);
res.json({ success: true, statuses });
} catch (error) {
console.error('List user statuses error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении статусов' });
}
});
router.post('/api/user-statuses',
requirePermission('users.manage'),
validateTenantAccess,
validateRequest(insertUserStatusSchema.omit({ organizationId: true })),
async (req: AuthenticatedRequest, res) => {
try {
const status = await userStatusesStorage.create({
...req.body,
organizationId: req.organizationId!,
});
res.json({ success: true, status });
} catch (error) {
console.error('Create user status error:', error);
res.status(500).json({ success: false, error: 'Ошибка при создании статуса' });
}
}
);
router.put('/api/user-statuses/:id',
requirePermission('users.manage'),
validateTenantAccess,
validateRequest(updateUserStatusSchema.partial()),
async (req: AuthenticatedRequest, res) => {
try {
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
return res.status(400).json({ success: false, error: 'Некорректный ID' });
}
const status = await userStatusesStorage.update(id, req.organizationId!, req.body);
res.json({ success: true, status });
} catch (error) {
console.error('Update user status error:', error);
res.status(500).json({ success: false, error: 'Ошибка при обновлении статуса' });
}
}
);
router.delete('/api/user-statuses/:id',
requirePermission('users.manage'),
validateTenantAccess,
async (req: AuthenticatedRequest, res) => {
try {
const id = Number(req.params.id);
if (!Number.isFinite(id)) {
return res.status(400).json({ success: false, error: 'Некорректный ID' });
}
await userStatusesStorage.delete(id, req.organizationId!);
res.json({ success: true });
} catch (error) {
console.error('Delete user status error:', error);
res.status(500).json({ success: false, error: 'Ошибка при удалении статуса' });
}
}
);
router.put('/api/users/:id(\d+)/status',
validateTenantAccess,
validateRequest(z.object({ statusId: z.union([z.number(), z.null()]) })),
async (req: AuthenticatedRequest, res) => {
try {
const userId = Number(req.params.id);
const { statusId } = req.body;
const user = await storage.getUser(userId);
if (!user || user.organizationId !== req.organizationId) {
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
}
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
if (!canEdit) {
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
}
if (statusId !== null) {
const status = await userStatusesStorage.getById(statusId);
if (!status || status.organizationId !== req.organizationId) {
return res.status(400).json({ success: false, error: 'Статус не найден' });
}
}
await storage.updateUser(userId, { statusId });
res.json({ success: true });
} catch (error) {
console.error('Update user status error:', error);
res.status(500).json({ success: false, error: 'Ошибка при изменении статуса' });
}
}
);
}