import express from 'express'; import path from 'path'; import fs from 'fs/promises'; import { storage } from "../storage"; import { GLOBAL_FIELD_TYPES } from "@shared/field-types"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { logAudit, getClientIp } from "../utils/audit"; import { notificationService } from "../services/notification.service"; export function registerContentRoutes(app: import("express").Express): void { // ======================================== // Field Templates API // ======================================== // Get all field templates for organization app.get('/api/field-templates', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const fields = await storage.getFieldTemplates(req.organizationId!); res.json(fields); } catch (error) { console.error('Get field templates error:', error); res.status(500).json({ error: 'Ошибка получения шаблонов полей' }); } }); // Get single field template app.get('/api/field-templates/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); const field = await storage.getFieldTemplate(id, req.organizationId!); if (!field) { return res.status(404).json({ error: 'Шаблон поля не найден' }); } res.json(field); } catch (error) { console.error('Get field template error:', error); res.status(500).json({ error: 'Ошибка получения шаблона поля' }); } }); // Create field template app.post('/api/field-templates', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { code, name, type, options, defaultValue, isRequired, placeholder, description, validationRules, companyAutofill } = req.body; if (!code || typeof code !== 'string' || !/^[a-z0-9_]+$/.test(code)) { return res.status(400).json({ error: 'Код шаблона обязателен и должен содержать только латинские буквы, цифры и подчеркивания' }); } if (!name || typeof name !== 'string') { return res.status(400).json({ error: 'Название шаблона обязательно' }); } if (!type || !GLOBAL_FIELD_TYPES.includes(type)) { return res.status(400).json({ error: 'Некорректный тип поля' }); } const existingField = await storage.getFieldTemplateByCode(code, req.organizationId!); if (existingField) { return res.status(400).json({ error: 'Шаблон с таким кодом уже существует' }); } const field = await storage.createFieldTemplate({ code, name, type, options: options || null, defaultValue: defaultValue || null, isRequired: isRequired || false, placeholder: placeholder || null, description: description || null, validationRules: validationRules || null, companyAutofill: companyAutofill || null, organizationId: req.organizationId!, createdBy: req.user!.id, }); res.status(201).json(field); } catch (error) { console.error('Create field template error:', error); res.status(500).json({ error: 'Ошибка создания шаблона поля' }); } }); // Update field template app.patch('/api/field-templates/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); const existing = await storage.getFieldTemplate(id, req.organizationId!); if (!existing) { return res.status(404).json({ error: 'Шаблон поля не найден' }); } const { code, name, type, options, defaultValue, isRequired, placeholder, description, validationRules, companyAutofill } = req.body; if (code !== undefined) { if (typeof code !== 'string' || !/^[a-z0-9_]+$/.test(code)) { return res.status(400).json({ error: 'Код шаблона должен содержать только латинские буквы, цифры и подчеркивания' }); } if (code !== existing.code) { const codeExists = await storage.getFieldTemplateByCode(code, req.organizationId!); if (codeExists) { return res.status(400).json({ error: 'Шаблон с таким кодом уже существует' }); } } } if (type !== undefined && !GLOBAL_FIELD_TYPES.includes(type)) { return res.status(400).json({ error: 'Некорректный тип поля' }); } const updates: Record = {}; if (code !== undefined) updates.code = code; if (name !== undefined) updates.name = name; if (type !== undefined) updates.type = type; if (options !== undefined) updates.options = options; if (defaultValue !== undefined) updates.defaultValue = defaultValue; if (isRequired !== undefined) updates.isRequired = isRequired; if (placeholder !== undefined) updates.placeholder = placeholder; if (description !== undefined) updates.description = description; if (validationRules !== undefined) updates.validationRules = validationRules; if (companyAutofill !== undefined) updates.companyAutofill = companyAutofill; const field = await storage.updateFieldTemplate(id, req.organizationId!, updates); res.json(field); } catch (error) { console.error('Update field template error:', error); res.status(500).json({ error: 'Ошибка обновления шаблона поля' }); } }); // Delete field template app.delete('/api/field-templates/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); const existing = await storage.getFieldTemplate(id, req.organizationId!); if (!existing) { return res.status(404).json({ error: 'Шаблон поля не найден' }); } await storage.deleteFieldTemplate(id, req.organizationId!); res.json({ success: true }); } catch (error) { console.error('Delete field template error:', error); res.status(500).json({ error: 'Ошибка удаления шаблона поля' }); } }); // Initialize notification event types on startup notificationService.initializeEventTypes().catch(err => console.error('Failed to initialize notification event types:', err) ); // ===================== // Tab Modules Management // ===================== const TAB_MODULES_DIR = path.join(process.cwd(), 'client/src/components/tabs'); // Built-in tab modules info const BUILT_IN_MODULES = [ { type: 'fields', label: 'Вкладка с полями', icon: 'FileText', file: 'FieldsTab.tsx' }, { type: 'table', label: 'Обычная таблица', icon: 'Table', file: 'RegularTableTab.tsx' } ]; // Get list of all tab modules (built-in + custom for organization) app.get('/api/tab-modules', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const modules: Array<{ id?: number; type: string; label: string; icon: string; isBuiltIn: boolean; hasFile: boolean; config?: Record; inputSchema?: unknown; isActive?: boolean; }> = []; // Add built-in modules for (const mod of BUILT_IN_MODULES) { const filePath = path.join(TAB_MODULES_DIR, mod.file); let hasFile = false; try { await fs.access(filePath); hasFile = true; } catch {} modules.push({ type: mod.type, label: mod.label, icon: mod.icon, isBuiltIn: true, hasFile }); } // Add custom modules for this organization const customModules = await storage.getCustomTabModules(req.organizationId!); for (const mod of customModules) { modules.push({ id: mod.id, type: mod.type, label: mod.label, icon: mod.icon || 'Puzzle', isBuiltIn: false, hasFile: false, config: mod.config as Record | undefined, inputSchema: mod.inputSchema, isActive: mod.isActive ?? true }); } res.json({ success: true, modules }); } catch (error) { console.error('Get tab modules error:', error); res.status(500).json({ error: 'Ошибка получения списка модулей' }); } }); // Check if tab module type is available app.get('/api/tab-modules/check-type/:type', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { type } = req.params; const excludeId = req.query.excludeId ? Number(req.query.excludeId) : undefined; if (BUILT_IN_MODULES.some(m => m.type === type)) { return res.json({ available: false, reason: 'builtin' }); } const existing = await storage.getCustomTabModuleByType(type, req.organizationId!); const taken = !!existing && existing.id !== excludeId; res.json({ available: !taken }); } catch (error) { console.error('Check module type error:', error); res.status(500).json({ error: 'Ошибка проверки типа' }); } }); // Create custom tab module app.post('/api/tab-modules', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const { type, label, icon, description, config, inputSchema } = req.body; if (!type || typeof type !== 'string' || !/^[a-z0-9_]+$/.test(type)) { return res.status(400).json({ error: 'Тип модуля обязателен и должен содержать только латинские буквы, цифры и подчеркивания' }); } if (!label || typeof label !== 'string') { return res.status(400).json({ error: 'Название модуля обязательно' }); } if (!config || typeof config !== 'object') { return res.status(400).json({ error: 'Конфигурация модуля обязательна' }); } // Check if type conflicts with built-in if (BUILT_IN_MODULES.some(m => m.type === type)) { return res.status(400).json({ error: 'Тип модуля совпадает со встроенным' }); } // Check if type already exists for this organization const existing = await storage.getCustomTabModuleByType(type, req.organizationId!); if (existing) { return res.status(400).json({ error: 'Модуль с таким типом уже существует' }); } const module = await storage.createCustomTabModule({ type, label, icon: icon || 'Puzzle', description, config, inputSchema: inputSchema || null, organizationId: req.organizationId!, createdBy: req.user!.id, }); res.status(201).json({ success: true, module }); } catch (error) { console.error('Create custom tab module error:', error); res.status(500).json({ error: 'Ошибка создания модуля' }); } }); // Update custom tab module app.patch('/api/tab-modules/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); const existing = await storage.getCustomTabModule(id, req.organizationId!); if (!existing) { return res.status(404).json({ error: 'Модуль не найден' }); } const { label, icon, description, config, inputSchema, isActive } = req.body; const updates: Record = {}; if (label !== undefined) updates.label = label; if (icon !== undefined) updates.icon = icon; if (description !== undefined) updates.description = description; if (config !== undefined) updates.config = config; if (inputSchema !== undefined) updates.inputSchema = inputSchema; if (isActive !== undefined) updates.isActive = isActive; const module = await storage.updateCustomTabModule(id, req.organizationId!, updates); res.json({ success: true, module }); } catch (error) { console.error('Update custom tab module error:', error); res.status(500).json({ error: 'Ошибка обновления модуля' }); } }); // Delete custom tab module app.delete('/api/tab-modules/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); const existing = await storage.getCustomTabModule(id, req.organizationId!); if (!existing) { return res.status(404).json({ error: 'Модуль не найден' }); } await storage.deleteCustomTabModule(id, req.organizationId!); res.json({ success: true }); } catch (error) { console.error('Delete custom tab module error:', error); res.status(500).json({ error: 'Ошибка удаления модуля' }); } }); // Download built-in tab module file (for developer reference) app.get('/api/tab-modules/:type/download', authenticateToken, async (req: AuthenticatedRequest, res) => { try { const { type } = req.params; // Only allow downloading built-in modules const builtIn = BUILT_IN_MODULES.find(m => m.type === type); if (!builtIn) { return res.status(404).json({ error: 'Модуль не найден' }); } const filePath = path.join(TAB_MODULES_DIR, builtIn.file); try { const content = await fs.readFile(filePath, 'utf-8'); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Content-Disposition', `attachment; filename="${builtIn.file}"`); logAudit({ action: 'export.tab_module', userId: req.user?.id ?? null, organizationId: req.organizationId! ?? null, details: { moduleType: type, fileName: builtIn.file }, ip: getClientIp(req), userAgent: req.headers['user-agent'] ?? null, }); res.send(content); } catch { res.status(404).json({ error: 'Файл модуля не найден' }); } } catch (error) { console.error('Download tab module error:', error); res.status(500).json({ error: 'Ошибка скачивания модуля' }); } }); // ===================== // Custom JS Pages // ===================== app.get('/api/custom-pages', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const pages = await storage.getCustomPages(req.organizationId!); res.json({ success: true, pages }); } catch (error) { console.error('Get custom pages error:', error); res.status(500).json({ error: 'Ошибка получения страниц' }); } }); app.get('/api/custom-pages/by-slug/:slug', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const page = await storage.getCustomPageBySlug(req.params.slug, req.organizationId!); if (!page) return res.status(404).json({ error: 'Страница не найдена' }); res.json({ success: true, page }); } catch (error) { console.error('Get custom page by slug error:', error); res.status(500).json({ error: 'Ошибка получения страницы' }); } }); app.get('/api/custom-pages/check-slug/:slug', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { slug } = req.params; const excludeId = req.query.excludeId ? Number(req.query.excludeId) : undefined; const page = await storage.getCustomPageBySlug(slug, req.organizationId!); const taken = !!page && page.id !== excludeId; res.json({ available: !taken }); } catch (error) { console.error('Check slug error:', error); res.status(500).json({ error: 'Ошибка проверки slug' }); } }); app.post('/api/custom-pages', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const { name, slug, description, code, formIds, icon, showInSidebar } = req.body; if (!name || !slug) return res.status(400).json({ error: 'name и slug обязательны' }); const page = await storage.createCustomPage({ organizationId: req.organizationId!, name, slug: slug.toLowerCase().replace(/[^a-z0-9-_]/g, '-'), description: description ?? null, code: code ?? '', formIds: formIds ?? [], icon: icon ?? 'FileCode', isActive: true, showInSidebar: showInSidebar === true, createdBy: req.user!.id, }); res.status(201).json({ success: true, page }); } catch (error: unknown) { if ((error as { code?: string })?.code === '23505') return res.status(409).json({ error: 'Страница с таким slug уже существует' }); console.error('Create custom page error:', error); res.status(500).json({ error: 'Ошибка создания страницы' }); } }); app.patch('/api/custom-pages/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); const { name, slug, description, code, formIds, icon, isActive, showInSidebar } = req.body; const updates: Record = {}; if (name !== undefined) updates.name = name; if (slug !== undefined) updates.slug = slug.toLowerCase().replace(/[^a-z0-9-_]/g, '-'); if (description !== undefined) updates.description = description; if (code !== undefined) updates.code = code; if (formIds !== undefined) updates.formIds = formIds; if (icon !== undefined) updates.icon = icon; if (isActive !== undefined) updates.isActive = isActive === true; if (showInSidebar !== undefined) updates.showInSidebar = showInSidebar === true; const page = await storage.updateCustomPage(id, req.organizationId!, updates); res.json({ success: true, page }); } catch (error: unknown) { if ((error as { code?: string })?.code === '23505') return res.status(409).json({ error: 'Slug уже используется' }); console.error('Update custom page error:', error); res.status(500).json({ error: 'Ошибка обновления страницы' }); } }); app.delete('/api/custom-pages/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id); await storage.deleteCustomPage(id, req.organizationId!); res.json({ success: true }); } catch (error) { console.error('Delete custom page error:', error); res.status(500).json({ error: 'Ошибка удаления страницы' }); } }); // ===================== // Task Tab Values (per-task data for custom modules) // ===================== // Get tab values for a specific task and tab app.get('/api/tasks/:taskId/tabs/:tabId/values', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.taskId); const tabId = parseInt(req.params.tabId); const values = await storage.getTaskTabValues(taskId, tabId); res.json({ success: true, values: values?.values || {} }); } catch (error) { console.error('Get task tab values error:', error); res.status(500).json({ error: 'Ошибка получения значений вкладки' }); } }); // Get all tab values for a task app.get('/api/tasks/:taskId/tab-values', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.taskId); const tabValues = await storage.getTaskTabValuesByTask(taskId); const valuesMap: Record> = {}; for (const tv of tabValues) { valuesMap[tv.tabId] = tv.values as Record; } res.json({ success: true, values: valuesMap }); } catch (error) { console.error('Get all task tab values error:', error); res.status(500).json({ error: 'Ошибка получения значений вкладок' }); } }); // Save/update tab values for a specific task and tab app.put('/api/tasks/:taskId/tabs/:tabId/values', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const taskId = parseInt(req.params.taskId); const tabId = parseInt(req.params.tabId); const { values } = req.body; if (!values || typeof values !== 'object') { return res.status(400).json({ error: 'Значения должны быть объектом' }); } // Get old values before overwrite for audit comparison const prevTabValues = await storage.getTaskTabValues(taskId, tabId); const prevValues = (prevTabValues?.values as Record) ?? {}; const saved = await storage.upsertTaskTabValues(taskId, tabId, values); // Audit log: detect changed keys const tabEditorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API'; const task = await storage.getTask(taskId, req.organizationId!); if (task) { const newValues = values as Record; const allKeys = new Set([...Object.keys(prevValues), ...Object.keys(newValues)]); for (const key of allKeys) { const oldStr = prevValues[key] === null || prevValues[key] === undefined ? '' : String(prevValues[key]); const newStr = newValues[key] === null || newValues[key] === undefined ? '' : String(newValues[key]); if (oldStr !== newStr) { storage.addTaskAuditLog({ taskId, organizationId: req.organizationId!, action: 'field.changed', fieldName: key, oldValue: prevValues[key] ?? null, newValue: newValues[key] ?? null, changedBy: req.user?.id ?? null, changedByName: tabEditorName, metadata: { tabId }, }).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); }); } } } res.json({ success: true, values: saved.values }); } catch (error) { console.error('Save task tab values error:', error); res.status(500).json({ error: 'Ошибка сохранения значений вкладки' }); } }); }