Files
iistwin/server/routes/user.routes.ts
Ильяс Султанов a60a75eda0 feat(profile): read-first редизайн страницы пользователя, per-field как в задачах
- Раскладка: шапка-профиль → Основное/Контакты → Роль и доступ/Поля+Замещение → вторичные разделы чипами
- ProfileFieldRow: двойной клик/карандаш, редактор в слоте, Esc, optimistic-кэш, все типы полей
- Замещение: надпись + ⊕ popover, список под строкой
- Удалены RHF-форма, zod-схема и кнопки «Сохранить»
- server: PATCH /api/users/:id (частичное обновление, admin-градация, аудит)
- server: history-number в REST PATCH пишет историю + пересчёт средней
2026-09-06 18:47:05 +03:00

1028 lines
41 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 {
listDelegations,
getDelegation,
createDelegation,
updateDelegation,
deleteDelegation,
bulkAssignDelegate,
} from "../services/delegation.service";
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: 'Ошибка при обновлении пользователя'
});
}
}
);
// Частичное обновление системных полей профиля (редизайн профиля):
// применяются только переданные ключи, непереданные поля не затираются.
// Права — как у 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: 'Ошибка при удалении замещения' });
}
}
);
}