- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
371 lines
16 KiB
TypeScript
371 lines
16 KiB
TypeScript
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: 'Ошибка при удалении формы' });
|
||
}
|
||
}
|
||
);
|
||
}
|