fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
370
server/routes/form.crud.routes.ts
Normal file
370
server/routes/form.crud.routes.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
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: 'Ошибка при удалении формы' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user