- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
537 lines
23 KiB
TypeScript
537 lines
23 KiB
TypeScript
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<string, unknown> = {};
|
||
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<string, unknown>;
|
||
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<string, unknown> | 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<string, unknown> = {};
|
||
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<string, any> = {};
|
||
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<number, Record<string, any>> = {};
|
||
for (const tv of tabValues) {
|
||
valuesMap[tv.tabId] = tv.values as Record<string, any>;
|
||
}
|
||
|
||
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<string, unknown>) ?? {};
|
||
|
||
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<string, unknown>;
|
||
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: 'Ошибка сохранения значений вкладки' });
|
||
}
|
||
});
|
||
|
||
}
|