Files
iistwin/server/routes/content.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

537 lines
23 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 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: 'Ошибка сохранения значений вкладки' });
}
});
}