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

351 lines
13 KiB
TypeScript

import { Router } from "express";
import { Worker } from 'node:worker_threads';
import v8 from 'v8';
import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { logAudit } from "../utils/audit";
import { AUTOMATION_WORKER_CODE } from "../workers/automation-worker-code";
const router = Router();
const WORKER_TIMEOUT_MS = 10_000;
// Storage methods that worker threads are allowed to call via IPC
const ALLOWED_STORAGE_METHODS = new Set([
'getFormsByOrganization',
'getForm',
'getFormStatuses',
'getTasksByForm',
'getTask',
'createTask',
'updateTask',
'deleteTask',
'getTaskFieldValues',
'getFormFields',
'getTaskAssignees',
'updateTaskFieldValue',
'createTaskFieldValue',
'getUser',
'getUsersByOrganization',
'updateUser',
]);
interface WorkerStorageCall {
type: 'storage-call';
callId: number;
method: string;
args: unknown[];
}
interface WorkerDone {
type: 'done';
logs: string[];
error?: string;
result?: unknown;
}
type WorkerMessage = WorkerStorageCall | WorkerDone;
function runAutomationInWorker(
code: string,
organizationId: number,
triggerData: unknown,
): Promise<{ logs: string[]; error?: string; result?: unknown }> {
return new Promise((resolve) => {
const worker = new Worker(AUTOMATION_WORKER_CODE, {
workerData: { code, organizationId, triggerData },
eval: true,
});
let settled = false;
let hardStop: ReturnType<typeof setTimeout>;
function cleanup(result: { logs: string[]; error?: string; result?: unknown }) {
if (settled) return;
settled = true;
clearTimeout(hardStop);
worker.removeAllListeners();
worker.terminate().catch(() => {});
resolve(result);
}
hardStop = setTimeout(() => {
cleanup({
logs: [`ERROR: Script execution timed out (${WORKER_TIMEOUT_MS / 1000}s) — worker terminated`],
error: `Script execution timed out (${WORKER_TIMEOUT_MS / 1000}s)`,
});
}, WORKER_TIMEOUT_MS);
worker.on('message', async (msg: WorkerMessage) => {
if (settled) return;
if (msg.type === 'storage-call') {
const { callId, method, args } = msg;
if (!ALLOWED_STORAGE_METHODS.has(method)) {
try {
worker.postMessage({
type: 'storage-response',
callId,
error: `Storage method '${method}' is not allowed`,
});
} catch { /* worker may have been terminated */ }
return;
}
try {
const fn = (storage as unknown as Record<string, (...a: unknown[]) => Promise<unknown>>)[method];
if (typeof fn !== 'function') {
throw new Error(`Storage method '${method}' not found`);
}
const result = await fn.apply(storage, args);
if (!settled) {
try { worker.postMessage({ type: 'storage-response', callId, result }); } catch { /* terminated */ }
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
if (!settled) {
try { worker.postMessage({ type: 'storage-response', callId, error: message }); } catch { /* terminated */ }
}
}
return;
}
if (msg.type === 'done') {
cleanup({ logs: msg.logs, error: msg.error, result: msg.result });
}
});
worker.on('error', err => {
cleanup({ logs: [`ERROR: Worker error — ${err.message}`], error: err.message });
});
worker.on('exit', code => {
cleanup({
logs: [`ERROR: Worker exited unexpectedly (code ${code})`],
error: `Worker exited unexpectedly (code ${code})`,
});
});
});
}
export interface AutomationRunResult {
automationId: number;
name: string;
logs: string[];
error?: string;
result?: unknown;
}
export async function runAutomationsByTrigger(
organizationId: number,
trigger: string,
triggerData: unknown,
): Promise<AutomationRunResult[]> {
const automations = await storage.getAutomations(organizationId);
const results: AutomationRunResult[] = [];
for (const automation of automations) {
if (!automation.isActive) continue;
if (automation.trigger !== trigger) continue;
const config = (automation.triggerConfig as Record<string, unknown>) || {};
const configFormId = config.formId;
if (configFormId !== undefined && triggerData && typeof triggerData === 'object') {
const data = triggerData as Record<string, unknown>;
const dataFormId = data.formId ?? (data.task as Record<string, unknown>)?.formId;
if (Number(configFormId) !== Number(dataFormId)) continue;
}
const { logs, error, result } = await runAutomationInWorker(
automation.code,
organizationId,
triggerData,
);
results.push({ automationId: automation.id, name: automation.name, logs, error, result });
}
return results;
}
// =====================
// Automations
// =====================
// GET /api/automations
router.get('/api/automations', authenticateToken, tenantIsolation, requirePermission('automations.manage'), async (req: AuthenticatedRequest, res) => {
try {
const list = await storage.getAutomations(req.organizationId!);
res.json({ success: true, automations: list });
} catch (err) {
console.error('Get automations error:', err);
res.status(500).json({ error: 'Ошибка загрузки автоматизаций' });
}
});
// GET /api/automations/:id
router.get('/api/automations/:id', authenticateToken, tenantIsolation, requirePermission('automations.manage'), async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const item = await storage.getAutomation(id, req.organizationId!);
if (!item) return res.status(404).json({ error: 'Автоматизация не найдена' });
res.json({ success: true, automation: item });
} catch (err) {
console.error('Get automation error:', err);
res.status(500).json({ error: 'Ошибка загрузки автоматизации' });
}
});
// POST /api/automations
router.post('/api/automations', authenticateToken, tenantIsolation, requirePermission('automations.manage'), async (req: AuthenticatedRequest, res) => {
try {
const { name, description, code, trigger, triggerConfig, isActive, runOffline, clientCompatible } = req.body;
if (!name?.trim()) return res.status(400).json({ error: 'Название обязательно' });
const item = await storage.createAutomation({
organizationId: req.organizationId!,
name: name.trim(),
description: description ?? null,
code: code ?? '',
trigger: trigger ?? 'manual',
triggerConfig: triggerConfig ?? null,
isActive: isActive ?? true,
runOffline: runOffline ?? false,
clientCompatible: clientCompatible ?? false,
createdBy: req.user!.id,
});
res.json({ success: true, automation: item });
} catch (err) {
console.error('Create automation error:', err);
res.status(500).json({ error: 'Ошибка создания автоматизации' });
}
});
// PUT /api/automations/:id
router.put('/api/automations/:id', authenticateToken, tenantIsolation, requirePermission('automations.manage'), async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const existing = await storage.getAutomation(id, req.organizationId!);
if (!existing) return res.status(404).json({ error: 'Автоматизация не найдена' });
const { name, description, code, trigger, triggerConfig, isActive, runOffline, clientCompatible } = req.body;
const updates: Record<string, unknown> = {};
if (name !== undefined) updates.name = name;
if (description !== undefined) updates.description = description;
if (code !== undefined) updates.code = code;
if (trigger !== undefined) updates.trigger = trigger;
if (triggerConfig !== undefined) updates.triggerConfig = triggerConfig;
if (isActive !== undefined) updates.isActive = isActive;
if (runOffline !== undefined) updates.runOffline = runOffline;
if (clientCompatible !== undefined) updates.clientCompatible = clientCompatible;
const item = await storage.updateAutomation(id, req.organizationId!, updates as any);
res.json({ success: true, automation: item });
} catch (err) {
console.error('Update automation error:', err);
res.status(500).json({ error: 'Ошибка обновления автоматизации' });
}
});
// DELETE /api/automations/:id
router.delete('/api/automations/:id', authenticateToken, tenantIsolation, requirePermission('automations.manage'), async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const existing = await storage.getAutomation(id, req.organizationId!);
if (!existing) return res.status(404).json({ error: 'Автоматизация не найдена' });
await storage.deleteAutomation(id, req.organizationId!);
res.json({ success: true });
} catch (err) {
console.error('Delete automation error:', err);
res.status(500).json({ error: 'Ошибка удаления автоматизации' });
}
});
// POST /api/automations/:id/run — execute automation in a Worker Thread
router.post('/api/automations/:id/run', authenticateToken, tenantIsolation, requirePermission('automations.manage'), async (req: AuthenticatedRequest, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const automation = await storage.getAutomation(id, req.organizationId!);
if (!automation) return res.status(404).json({ error: 'Автоматизация не найдена' });
const orgId = req.organizationId!;
// Memory guard: reject if process heap is > 80% of the V8 heap limit
const heapLimit = v8.getHeapStatistics().heap_size_limit;
const { heapUsed } = process.memoryUsage();
if (heapUsed > heapLimit * 0.8) {
const usedMB = Math.round(heapUsed / 1024 / 1024);
const limitMB = Math.round(heapLimit / 1024 / 1024);
console.warn(`[automation] Rejected run of automation ${automation.id} — heap ${usedMB}/${limitMB} MB`);
return res.json({
success: false,
logs: [`REJECTED: heap usage too high (${usedMB} MB / ${limitMB} MB)`],
error: 'Недостаточно памяти для запуска автоматизации',
});
}
console.log(`[automation] Starting worker for automation ${automation.id} (org=${orgId})`);
const { logs, error, result } = await runAutomationInWorker(
automation.code,
orgId,
req.body.triggerData ?? null,
);
if (error) {
const taskId: number | undefined =
typeof req.body.triggerData?.taskId === 'number' ? req.body.triggerData.taskId : undefined;
if (taskId) {
storage.addTaskAuditLog({
taskId,
organizationId: orgId,
action: 'automation.error',
fieldName: 'automation',
newValue: String(automation.id),
changedBy: req.user?.id ?? null,
changedByName: 'system',
metadata: {
automationId: automation.id,
automationName: automation.name,
error,
},
}).catch(e => console.error('[automation] Failed to log error to task_audit_log:', e));
} else {
logAudit({
action: 'automation.error',
userId: req.user?.id ?? null,
organizationId: orgId,
details: {
automationId: automation.id,
automationName: automation.name,
error,
},
});
}
return res.json({ success: false, logs, error });
}
res.json({ success: true, logs, result });
});
// POST /api/automations/run-by-trigger — execute all automations matching a trigger
router.post('/api/automations/run-by-trigger', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const { trigger, triggerData } = req.body;
if (!trigger || typeof trigger !== 'string') {
return res.status(400).json({ error: 'Неверный триггер' });
}
const results = await runAutomationsByTrigger(req.organizationId!, trigger, triggerData ?? null);
res.json({ success: true, results });
} catch (err) {
console.error('Run by trigger error:', err);
res.status(500).json({ error: 'Ошибка выполнения автоматизаций' });
}
});
export default router;