Files
iistwin/server/routes/form.crud.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

371 lines
16 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 { storage } from "../storage";
import { requireFormAdminOrPermission, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { insertFormSchema, type Form } from "@shared/schema";
import { formsCache } from "../utils/cache";
import { logAudit, getClientIp } from "../utils/audit";
import { formatUserName } from "../utils/formatUserName";
import { indexFormWithSummaryAsync } from "./task-helpers";
import { recalculateTaskTitles } from "./shared";
export function registerFormCrudRoutes(router: ReturnType<typeof import("express").Router>): void {
async function filterFormsByAccess(
forms: Form[],
userId: number,
organizationId: number,
appRole: string
): Promise<Form[]> {
const hasManageForms = await storage.hasAppRolePermission(appRole, ['forms.manage']);
if (hasManageForms) return forms;
const results = await Promise.all(
forms.map(async (f) => {
const canAccess = await storage.canUserAccessForm(userId, f.id, organizationId, 'participate');
return canAccess ? f : null;
})
);
return results.filter(Boolean) as Form[];
}
router.get('/api/forms', async (req: AuthenticatedRequest, res) => {
try {
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';
const hasFilters = Object.keys(filtersObj).some(k => filtersObj[k]) || !!sortColumn;
const getFormColVal = (f: Form, col: string): string => {
if (col === 'name') return f.name || '';
if (col === 'status') return f.isActive ? 'Активна' : 'Неактивна';
if (col === 'createdAt') return f.createdAt ? new Date(f.createdAt).toLocaleDateString('ru-RU') : '';
return '';
};
const userId = req.user!.id;
const organizationId = req.organizationId!;
const appRole = req.user!.appRole;
const hasManageForms = await storage.hasAppRolePermission(appRole, ['forms.manage']);
if (!hasFilters) {
if (hasManageForms) {
const cacheKey = `forms:${organizationId}`;
const cached = formsCache.get<Form[]>(cacheKey);
if (cached) {
return res.json({ success: true, forms: cached });
}
const forms = await storage.getFormsByOrganization(organizationId);
formsCache.set(cacheKey, forms);
return res.json({ success: true, forms });
}
// Non-admins: load without cache and filter by access
const forms = await storage.getFormsByOrganization(organizationId);
const accessible = await filterFormsByAccess(forms, userId, organizationId, appRole);
return res.json({ success: true, forms: accessible });
}
let forms = await storage.getFormsByOrganization(organizationId);
Object.entries(filtersObj).forEach(([col, val]) => {
if (!val) return;
const vl = (val as string).toLowerCase();
forms = forms.filter(f => getFormColVal(f, col).toLowerCase().includes(vl));
});
if (sortColumn) {
forms = forms.sort((a, b) => {
if (sortColumn === 'createdAt' || sortColumn === 'updatedAt') {
const _av = (a as Record<string, unknown>)[sortColumn];
const _bv = (b as Record<string, unknown>)[sortColumn];
const av = _av ? new Date(String(_av)).getTime() : 0;
const bv = _bv ? new Date(String(_bv)).getTime() : 0;
return sortDirection === 'asc' ? av - bv : bv - av;
}
const av = getFormColVal(a, sortColumn).toLowerCase();
const bv = getFormColVal(b, sortColumn).toLowerCase();
const cmp = av.localeCompare(bv, 'ru');
return sortDirection === 'asc' ? cmp : -cmp;
});
}
if (!hasManageForms) {
forms = await filterFormsByAccess(forms, userId, organizationId, appRole);
}
res.json({ success: true, forms });
} catch (error) {
console.error('Get forms error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении форм' });
}
});
router.get('/api/forms/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 {}
const organizationId = req.organizationId!;
let forms = await storage.getFormsByOrganization(organizationId);
const getFormColVal2 = (f: Form, col: string): string => {
if (col === 'name') return f.name || '';
if (col === 'status') return f.isActive ? 'Активна' : 'Неактивна';
if (col === 'createdAt') return f.createdAt ? new Date(f.createdAt).toLocaleDateString('ru-RU') : '';
return '';
};
Object.entries(context).forEach(([col, val]) => {
if (!val) return;
const vl = (val as string).toLowerCase();
forms = forms.filter(f => getFormColVal2(f, col).toLowerCase().includes(vl));
});
forms = await filterFormsByAccess(forms, req.user!.id, organizationId, req.user!.appRole);
const seen = new Set<string>();
const values: string[] = [];
for (const f of forms) {
const val = getFormColVal2(f, 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: 'Ошибка получения значений' });
}
}
);
/**
* @swagger
* /api/forms/{id}:
* get:
* tags: [Forms]
* summary: Получить форму
*/
router.get('/api/forms/:id(\\d+)', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const form = await storage.getForm(formId, req.organizationId!);
if (!form) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const hasManageForms = await storage.hasAppRolePermission(req.user!.appRole, ['forms.manage']);
if (!hasManageForms) {
const canAccess = await storage.canUserAccessForm(req.user!.id, formId, req.organizationId!, 'participate');
if (!canAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' });
}
}
res.json({ success: true, form });
} catch (error) {
console.error('Get form error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении формы' });
}
});
router.get('/api/forms/:id/editor', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const organizationId = req.organizationId!;
const [form, fields, statuses, transitions, tabs, rawUsers, orgRoles, canManageForm] = await Promise.all([
storage.getForm(formId, organizationId),
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
storage.getFormTabs(formId, organizationId),
storage.getUsersByOrganization(organizationId),
storage.getRoles(organizationId),
storage.canUserAccessForm(req.user!.id, formId, organizationId, 'admin')
]);
if (!form) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const users = rawUsers.map(user => ({
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
fullName: formatUserName(user),
position: user.position,
appRole: user.appRole,
isActive: user.isActive,
emailVerified: user.emailVerified,
lastLogin: user.lastLogin,
createdAt: user.createdAt
}));
res.json({
success: true,
form, fields, statuses, transitions, tabs, users,
roles: orgRoles.map(r => ({ id: r.id, name: r.name })),
canManageForm
});
} catch (error) {
console.error('Get form editor data error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении данных формы' });
}
});
router.get('/api/forms/:id/export',
requireFormAdminOrPermission('forms.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const organizationId = req.organizationId!;
const [form, fields, statuses, transitions, tabs] = await Promise.all([
storage.getForm(formId, organizationId),
storage.getFormFields(formId, organizationId),
storage.getFormStatuses(formId, organizationId),
storage.getStatusTransitions(formId, organizationId),
storage.getFormTabs(formId, organizationId),
]);
if (!form) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
logAudit({
action: 'export.form_config',
userId: req.user?.id ?? null,
organizationId,
details: { formId, formName: form.name },
ip: getClientIp(req),
userAgent: req.headers['user-agent'] ?? null,
});
res.json({
success: true,
form: {
id: form.id, name: form.name, description: form.description, isActive: form.isActive,
chatLayout: form.chatLayout, chatEnabled: form.chatEnabled, titleTemplate: form.titleTemplate,
},
tabs: tabs.map(tab => ({
id: tab.id, name: tab.name, code: tab.code, type: tab.type,
position: tab.position, tableColumns: tab.tableColumns,
})),
fields: fields.map(field => ({
id: field.id, tabId: field.tabId, name: field.name, code: field.code,
type: field.type, isRequired: field.isRequired, placeholder: field.placeholder,
defaultValue: field.defaultValue, options: field.options,
validationRules: field.validationRules, position: field.position,
linkedFormId: field.linkedFormId, taskRelationType: field.taskRelationType,
buttonActionType: field.buttonActionType, buttonUrl: field.buttonUrl,
buttonFormId: field.buttonFormId, companyAutofill: field.companyAutofill,
})),
statuses: statuses.map(status => ({
id: status.id, name: status.name, color: status.color,
position: status.position, isInitial: status.isInitial, isFinal: status.isFinal,
})),
transitions: transitions.map(tr => ({
id: tr.id, fromStatusId: tr.fromStatusId, toStatusId: tr.toStatusId,
rejectToStatusId: tr.rejectToStatusId, actionType: tr.actionType,
actionName: tr.actionName, requiredRole: tr.requiredRole,
approveButtonText: tr.approveButtonText, approveButtonColor: tr.approveButtonColor,
rejectButtonText: tr.rejectButtonText, rejectButtonColor: tr.rejectButtonColor,
})),
});
} catch (error) {
console.error('Export form error:', error);
res.status(500).json({ success: false, error: 'Ошибка при экспорте формы' });
}
}
);
router.post('/api/forms',
requirePermission('forms.manage'),
validateRequest(insertFormSchema.omit({ organizationId: true, createdBy: true })),
async (req: AuthenticatedRequest, res) => {
try {
const formData = {
...req.body,
organizationId: req.organizationId!,
createdBy: req.user!.id
};
const form = await storage.createForm(formData);
formsCache.invalidatePrefix(`forms:${req.organizationId}`);
indexFormWithSummaryAsync(form.id, req.organizationId!).catch(() => {});
res.status(201).json({ success: true, message: 'Форма создана', form });
} catch (error) {
console.error('Create form error:', error);
res.status(500).json({ success: false, error: 'Ошибка при создании формы' });
}
}
);
router.get('/api/forms/:id/tasks-count', async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const counts = await storage.getTasksCountByForm(formId);
res.json({ success: true, ...counts });
} catch (error) {
console.error('Get tasks count error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении количества задач' });
}
});
router.post('/api/forms/:id/recalculate-titles',
requireFormAdminOrPermission('forms.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
if (!existingForm.titleTemplate) {
return res.status(400).json({ success: false, error: 'У формы нет шаблона названия' });
}
const result = await recalculateTaskTitles(formId, req.organizationId!);
res.json({ success: true, ...result });
} catch (error) {
console.error('Recalculate titles error:', error);
res.status(500).json({ success: false, error: 'Ошибка при пересчёте названий' });
}
}
);
router.delete('/api/forms/:id',
requireFormAdminOrPermission('forms.manage'),
async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) {
return res.status(400).json({ success: false, error: 'Неверный ID формы' });
}
const existingForm = await storage.getForm(formId, req.organizationId!);
if (!existingForm) {
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
await storage.deleteForm(formId, req.organizationId!);
formsCache.invalidatePrefix(`forms:${req.organizationId}`);
res.json({ success: true, message: 'Форма удалена' });
} catch (error) {
console.error('Delete form error:', error);
res.status(500).json({ success: false, error: 'Ошибка при удалении формы' });
}
}
);
}