Реестр /users: - кнопка «Колонки» — чекбоксы базовых колонок и кастомных полей профиля (Оценка, Средняя оценка и др.), скрытые ключи в localStorage; - /api/users/list?includeFieldValues=1 — значения полей текущей страницы; - поля типа history-number (Оценка) кликабельны — открывают график истории. График истории (FieldHistoryDialog): - точки красные и крупные (на тёмном фоне дефолтные не видны), линия primary; - клик по точке — под графиком список оценок за бакет со ссылками на задачи (название задачи → /forms/<formId>/tasks/<id>); - API field-history возвращает entries (value, taskId, taskTitle, formId, bucket).
1044 lines
42 KiB
TypeScript
1044 lines
42 KiB
TypeScript
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 {
|
||
listDelegations,
|
||
getDelegation,
|
||
createDelegation,
|
||
updateDelegation,
|
||
deleteDelegation,
|
||
bulkAssignDelegate,
|
||
} from "../services/delegation.service";
|
||
import { db } from "../db";
|
||
import { roles, roleMembers, userStatuses, userProfileFieldValues, 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 });
|
||
}
|
||
|
||
// Значения кастомных полей профиля — для реестра пользователей (выбор колонок).
|
||
// Только для текущей страницы (userIds), опционально по includeFieldValues=1.
|
||
const includeFieldValues = req.query.includeFieldValues === '1';
|
||
const fieldValuesByUser = new Map<number, Record<number, unknown>>();
|
||
if (includeFieldValues && userIds.length > 0) {
|
||
const fvRows = await db
|
||
.select({ userId: userProfileFieldValues.userId, fieldId: userProfileFieldValues.fieldId, value: userProfileFieldValues.value })
|
||
.from(userProfileFieldValues)
|
||
.where(inArray(userProfileFieldValues.userId, userIds));
|
||
for (const row of fvRows) {
|
||
if (!fieldValuesByUser.has(row.userId)) fieldValuesByUser.set(row.userId, {});
|
||
fieldValuesByUser.get(row.userId)![row.fieldId] = row.value;
|
||
}
|
||
}
|
||
|
||
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) || [],
|
||
...(includeFieldValues ? { fieldValues: fieldValuesByUser.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: 'Ошибка при обновлении пользователя'
|
||
});
|
||
}
|
||
}
|
||
);
|
||
|
||
// Частичное обновление системных полей профиля (редизайн профиля):
|
||
// применяются только переданные ключи, непереданные поля не затираются.
|
||
// Права — как у PUT: admin / сам пользователь / руководитель по орг-дереву (canManageUser).
|
||
router.patch('/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: 'Недостаточно прав' });
|
||
}
|
||
|
||
const isAdmin = req.user?.appRole === 'admin';
|
||
const body = req.body ?? {};
|
||
|
||
// appRole/isActive/statusId — только админ (как в PUT); попытка не-админа — явный 403
|
||
const adminOnlyKeys = ['appRole', 'isActive', 'statusId'] as const;
|
||
if (!isAdmin && adminOnlyKeys.some((key) => body[key] !== undefined)) {
|
||
return res.status(403).json({
|
||
success: false,
|
||
error: 'Изменение роли, активности и статуса доступно только администратору'
|
||
});
|
||
}
|
||
|
||
if (body.appRole !== undefined && !['admin', 'user', 'accountant'].includes(body.appRole)) {
|
||
return res.status(400).json({ success: false, error: 'Некорректная роль пользователя' });
|
||
}
|
||
|
||
if (body.email !== undefined) {
|
||
const email = String(body.email).trim();
|
||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||
return res.status(400).json({ success: false, error: 'Некорректный email' });
|
||
}
|
||
const existingWithEmail = await storage.getUserByEmail(email, req.organizationId!);
|
||
if (existingWithEmail && existingWithEmail.id !== userId) {
|
||
return res.status(400).json({ success: false, error: 'Пользователь с таким email уже существует' });
|
||
}
|
||
body.email = email;
|
||
}
|
||
|
||
const editableKeys = [
|
||
'firstName', 'lastName', 'middleName', 'email', 'phone', 'position',
|
||
'additionalPhones', 'additionalEmails',
|
||
] as const;
|
||
const updates: Partial<typeof user> = {};
|
||
for (const key of editableKeys) {
|
||
if (body[key] !== undefined) {
|
||
(updates as Record<string, unknown>)[key] = body[key];
|
||
}
|
||
}
|
||
if (isAdmin) {
|
||
for (const key of adminOnlyKeys) {
|
||
if (body[key] !== undefined) {
|
||
(updates as Record<string, unknown>)[key] = body[key];
|
||
}
|
||
}
|
||
}
|
||
|
||
if (Object.keys(updates).length === 0) {
|
||
return res.status(400).json({ success: false, error: 'Нет полей для обновления' });
|
||
}
|
||
|
||
const updatedUser = await storage.updateUser(userId, updates);
|
||
|
||
// Аудит — как в PUT: контактные системные поля профиля (+ email, который PATCH также принимает)
|
||
const auditFields = [
|
||
{ name: 'Email', old: user.email, new: updates.email },
|
||
{ 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 (auditField.new === undefined) continue;
|
||
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 && body.appRole !== undefined && body.appRole !== user.appRole) {
|
||
logAudit({
|
||
action: 'user.role.changed',
|
||
userId: req.user!.id,
|
||
organizationId: req.organizationId!,
|
||
details: {
|
||
targetUserId: userId,
|
||
oldRole: user.appRole,
|
||
newRole: body.appRole,
|
||
},
|
||
ip: getClientIp(req),
|
||
userAgent: req.headers['user-agent'] ?? null,
|
||
});
|
||
}
|
||
|
||
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,
|
||
statusId: updatedUser.statusId,
|
||
emailVerified: updatedUser.emailVerified,
|
||
lastLogin: updatedUser.lastLogin,
|
||
createdAt: updatedUser.createdAt
|
||
}
|
||
});
|
||
} catch (error) {
|
||
console.error('Patch user error:', error);
|
||
res.status(500).json({
|
||
success: false,
|
||
error: 'Ошибка при обновлении пользователя'
|
||
});
|
||
}
|
||
}
|
||
);
|
||
|
||
|
||
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: 'Ошибка при изменении статуса' });
|
||
}
|
||
}
|
||
);
|
||
|
||
// === Замещение пользователей (delegation/substitution) ===
|
||
|
||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
router.get('/api/users/:id(\\d+)/delegations',
|
||
validateTenantAccess,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
const target = await storage.getUser(userId);
|
||
if (!target || target.organizationId !== req.organizationId) {
|
||
return res.status(404).json({ success: false, error: 'Пользователь не найден' });
|
||
}
|
||
// Чтение доступно всем сотрудникам организации: коллеги видят бейдж
|
||
// «Замещает: …» в профиле (как в Pyrus). Мутации — только владельцу/админу.
|
||
const delegations = await listDelegations(userId, req.organizationId!);
|
||
res.json({ success: true, delegations });
|
||
} catch (error) {
|
||
console.error('List delegations error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при получении замещений' });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.post('/api/users/:id(\\d+)/delegations',
|
||
validateTenantAccess,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
const { delegateUserId, formId, dateFrom, dateTo } = req.body ?? {};
|
||
const target = await storage.getUser(userId);
|
||
if (!target || target.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: 'Недостаточно прав' });
|
||
}
|
||
|
||
const delegateId = Number(delegateUserId);
|
||
if (!Number.isFinite(delegateId)) {
|
||
return res.status(400).json({ success: false, error: 'Укажите заместителя' });
|
||
}
|
||
if (delegateId === userId) {
|
||
return res.status(400).json({ success: false, error: 'Нельзя назначить заместителем самого себя' });
|
||
}
|
||
const delegate = await storage.getUser(delegateId);
|
||
if (!delegate || delegate.organizationId !== req.organizationId || !delegate.isActive) {
|
||
return res.status(400).json({ success: false, error: 'Заместитель не найден или неактивен' });
|
||
}
|
||
if (typeof dateFrom !== 'string' || !DATE_RE.test(dateFrom) ||
|
||
typeof dateTo !== 'string' || !DATE_RE.test(dateTo)) {
|
||
return res.status(400).json({ success: false, error: 'Укажите даты в формате YYYY-MM-DD' });
|
||
}
|
||
if (dateFrom > dateTo) {
|
||
return res.status(400).json({ success: false, error: 'Дата начала не может быть позже даты окончания' });
|
||
}
|
||
let formIdVal: number | null = null;
|
||
if (formId !== undefined && formId !== null) {
|
||
formIdVal = Number(formId);
|
||
if (!Number.isFinite(formIdVal)) {
|
||
return res.status(400).json({ success: false, error: 'Некорректная форма' });
|
||
}
|
||
const form = await storage.getForm(formIdVal, req.organizationId!);
|
||
if (!form) {
|
||
return res.status(400).json({ success: false, error: 'Форма не найдена' });
|
||
}
|
||
}
|
||
|
||
const result = await createDelegation({
|
||
organizationId: req.organizationId!,
|
||
userId,
|
||
delegateUserId: delegateId,
|
||
formId: formIdVal,
|
||
dateFrom,
|
||
dateTo,
|
||
createdBy: req.user?.id ?? null,
|
||
});
|
||
if (result.conflict) {
|
||
return res.status(409).json({
|
||
success: false,
|
||
error: 'Замещение для этого заместителя с пересекающимся периодом уже существует',
|
||
});
|
||
}
|
||
|
||
logAudit({
|
||
action: 'delegation.created',
|
||
userId: req.user?.id ?? null,
|
||
organizationId: req.organizationId!,
|
||
details: {
|
||
delegationId: result.delegation.id,
|
||
absentUserId: userId,
|
||
delegateUserId: delegateId,
|
||
formId: formIdVal,
|
||
dateFrom,
|
||
dateTo,
|
||
},
|
||
ip: getClientIp(req),
|
||
});
|
||
|
||
// Массовое до-назначение заместителя в текущие задачи — асинхронно
|
||
bulkAssignDelegate(result.delegation.id).catch((err) =>
|
||
console.error('[Delegation] bulkAssignDelegate after create error:', err)
|
||
);
|
||
|
||
res.json({ success: true, delegation: result.delegation });
|
||
} catch (error) {
|
||
console.error('Create delegation error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при создании замещения' });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.patch('/api/users/:id(\\d+)/delegations/:delegationId(\\d+)',
|
||
validateTenantAccess,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
const delegationId = Number(req.params.delegationId);
|
||
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
|
||
if (!canEdit) {
|
||
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
|
||
}
|
||
|
||
const existing = await getDelegation(delegationId);
|
||
if (!existing || existing.userId !== userId || existing.organizationId !== req.organizationId) {
|
||
return res.status(404).json({ success: false, error: 'Замещение не найдено' });
|
||
}
|
||
|
||
const { isActive, dateFrom, dateTo } = req.body ?? {};
|
||
const patch: { isActive?: boolean; dateFrom?: string; dateTo?: string } = {};
|
||
if (isActive !== undefined) {
|
||
if (typeof isActive !== 'boolean') {
|
||
return res.status(400).json({ success: false, error: 'isActive должно быть boolean' });
|
||
}
|
||
patch.isActive = isActive;
|
||
}
|
||
if (dateFrom !== undefined) {
|
||
if (typeof dateFrom !== 'string' || !DATE_RE.test(dateFrom)) {
|
||
return res.status(400).json({ success: false, error: 'dateFrom в формате YYYY-MM-DD' });
|
||
}
|
||
patch.dateFrom = dateFrom;
|
||
}
|
||
if (dateTo !== undefined) {
|
||
if (typeof dateTo !== 'string' || !DATE_RE.test(dateTo)) {
|
||
return res.status(400).json({ success: false, error: 'dateTo в формате YYYY-MM-DD' });
|
||
}
|
||
patch.dateTo = dateTo;
|
||
}
|
||
if (Object.keys(patch).length === 0) {
|
||
return res.status(400).json({ success: false, error: 'Нет полей для обновления' });
|
||
}
|
||
|
||
const newFrom = patch.dateFrom ?? existing.dateFrom;
|
||
const newTo = patch.dateTo ?? existing.dateTo;
|
||
if (newFrom > newTo) {
|
||
return res.status(400).json({ success: false, error: 'Дата начала не может быть позже даты окончания' });
|
||
}
|
||
|
||
const updated = await updateDelegation(delegationId, userId, req.organizationId!, patch);
|
||
if (!updated) {
|
||
return res.status(404).json({ success: false, error: 'Замещение не найдено' });
|
||
}
|
||
|
||
logAudit({
|
||
action: 'delegation.updated',
|
||
userId: req.user?.id ?? null,
|
||
organizationId: req.organizationId!,
|
||
details: { delegationId, absentUserId: userId, patch },
|
||
ip: getClientIp(req),
|
||
});
|
||
|
||
// При активации/продлении — до-назначить заместителя в текущие задачи
|
||
if (updated.isActive) {
|
||
bulkAssignDelegate(updated.id).catch((err) =>
|
||
console.error('[Delegation] bulkAssignDelegate after update error:', err)
|
||
);
|
||
}
|
||
|
||
res.json({ success: true, delegation: updated });
|
||
} catch (error) {
|
||
console.error('Update delegation error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при обновлении замещения' });
|
||
}
|
||
}
|
||
);
|
||
|
||
router.delete('/api/users/:id(\\d+)/delegations/:delegationId(\\d+)',
|
||
validateTenantAccess,
|
||
async (req: AuthenticatedRequest, res) => {
|
||
try {
|
||
const userId = Number(req.params.id);
|
||
const delegationId = Number(req.params.delegationId);
|
||
const canEdit = await canManageUser(req.user, userId, req.organizationId!);
|
||
if (!canEdit) {
|
||
return res.status(403).json({ success: false, error: 'Недостаточно прав' });
|
||
}
|
||
const deleted = await deleteDelegation(delegationId, userId, req.organizationId!);
|
||
if (!deleted) {
|
||
return res.status(404).json({ success: false, error: 'Замещение не найдено' });
|
||
}
|
||
logAudit({
|
||
action: 'delegation.deleted',
|
||
userId: req.user?.id ?? null,
|
||
organizationId: req.organizationId!,
|
||
details: { delegationId, absentUserId: userId },
|
||
ip: getClientIp(req),
|
||
});
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
console.error('Delete delegation error:', error);
|
||
res.status(500).json({ success: false, error: 'Ошибка при удалении замещения' });
|
||
}
|
||
}
|
||
);
|
||
}
|