Files
iistwin/server/routes/auth.users.routes.ts

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