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