fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

View File

@@ -0,0 +1,666 @@
import express from 'express';
import crypto from 'crypto';
import type { Response, NextFunction } from "express";
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { botMessageSchema, conversations, conversationMessages, conversationMembers, users, bots } from "@shared/schema";
import { verifyAccessToken } from "../utils/jwt";
import { sendWebhook } from "../utils/webhook";
import { eventBus } from "./shared";
import { pushTaskUpdated } from "../utils/pushTaskUpdated";
import { decrypt } from "../crypto";
import { withSuperAdmin, openTenantCtx, _tenantCtx, db } from "../db";
import { eq, and, desc, sql, lt, inArray } from "drizzle-orm";
import { enrichMessage, getMembersForSSE } from "./messenger.helpers";
import { notificationService } from "../services/notification.service";
export function registerBotApiRoutes(app: import("express").Express): void {
// =====================================================
// BOT ACTIONS API - для ботов отправлять сообщения
// =====================================================
// Middleware для аутентификации ботов
// Поддерживает два типа токенов:
// 1. Обычный токен бота (role: 'bot') - получается через /api/bot/auth/login
// 2. Сервисный токен (type: 'bot_service') - передаётся в webhook при @mention
const authenticateBot = async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res: Response, next: NextFunction) => {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({
success: false,
error: 'Токен не предоставлен'
});
}
const token = authHeader.substring(7);
// Попробуем сначала как обычный токен бота
let botId: number;
let organizationId: number;
try {
const decoded = verifyAccessToken(token);
if (decoded.appRole === 'bot') {
// Обычный токен бота (userId содержит botId)
botId = decoded.userId;
organizationId = decoded.organizationId;
} else {
throw new Error('Not a bot token');
}
} catch {
// Попробуем как сервисный токен
try {
const { verifyBotServiceToken } = await import('../utils/jwt');
const serviceDecoded = verifyBotServiceToken(token);
botId = serviceDecoded.botId;
organizationId = serviceDecoded.organizationId;
} catch {
return res.status(401).json({
success: false,
error: 'Недействительный токен бота'
});
}
}
const bot = await withSuperAdmin(() => storage.getBot(botId, organizationId));
if (!bot || !bot.isActive) {
return res.status(401).json({
success: false,
error: 'Бот не найден или деактивирован'
});
}
req.bot = bot;
req.organizationId! = organizationId;
if (_tenantCtx.getStore()) {
return next();
}
openTenantCtx(organizationId)
.then((handle) => {
handle.run(() => {
const guard = setTimeout(() => {
console.warn(`[POOL] Force-releasing bot tenant connection after 30s timeout (${req.method} ${req.path})`);
handle.release();
}, 30_000);
res.once('finish', () => { clearTimeout(guard); handle.release(); });
res.once('close', () => { clearTimeout(guard); handle.release(); });
next();
});
})
.catch((err: Error) => next(err));
} catch (error) {
console.error('Bot auth error:', error);
res.status(401).json({
success: false,
error: 'Ошибка аутентификации бота'
});
}
};
// Bot sends message with buttons
app.post('/api/bot/messages',
authenticateBot,
validateRequest(botMessageSchema),
async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res) => {
try {
const { taskId, message, buttons, replyToMessageId } = req.body;
// Проверить что задача существует
const task = await storage.getTask(taskId, req.organizationId!);
if (!task) {
return res.status(404).json({
success: false,
error: 'Задача не найдена'
});
}
// Создать сообщение от бота
const botMessage = await storage.createTaskMessage({
taskId,
formId: task.formId,
authorId: null, // null для бота
botId: req.bot!.id,
replyToMessageId: replyToMessageId || null,
message,
messageType: 'bot',
mentionedUserIds: null,
botButtons: buttons || null
}, req.organizationId!);
// Публикуем событие через SSE
eventBus.publishEvent({
type: 'message_created',
organizationId: req.organizationId!,
taskId,
data: {
taskId,
message: {
...botMessage,
bot: {
id: req.bot!.id,
name: req.bot!.name,
avatarUrl: req.bot!.avatarUrl
}
}
}
});
res.status(201).json({
success: true,
message: 'Сообщение отправлено',
messageId: botMessage.id
});
} catch (error) {
console.error('Bot send message error:', error);
res.status(500).json({
success: false,
error: 'Ошибка при отправке сообщения'
});
}
}
);
// Bot callback handler - обработка нажатий на кнопки
app.post('/api/bot/callback',
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const { messageId, callbackData } = req.body;
if (!messageId || !callbackData) {
return res.status(400).json({
success: false,
error: 'messageId и callbackData обязательны'
});
}
// Получить сообщение
const message = await storage.getTaskMessage(messageId, req.organizationId!);
if (!message || !message.botId) {
return res.status(404).json({
success: false,
error: 'Сообщение бота не найдено'
});
}
// Получить бота
const bot = await storage.getBot(message.botId, req.organizationId!);
if (!bot || !bot.webhookUrl || !bot.webhookEnabled) {
return res.status(400).json({
success: false,
error: 'Бот не настроен для обработки callback'
});
}
// Получить задачу
const task = await storage.getTask(message.taskId, req.organizationId!);
// Отправить callback в n8n webhook
const callbackPayload = {
event: 'button_callback',
callbackData,
messageId,
taskId: message.taskId,
task: task,
user: {
id: req.user!.id,
firstName: req.user!.firstName,
middleName: req.user!.middleName,
lastName: req.user!.lastName,
email: req.user!.email
},
timestamp: new Date().toISOString()
};
// Создаём подпись HMAC если есть secret
let signature = '';
if (bot.webhookSecret) {
const secret = decrypt(bot.webhookSecret);
signature = crypto
.createHmac('sha256', secret)
.update(JSON.stringify(callbackPayload))
.digest('hex');
}
// Отправляем в n8n
await sendWebhook(
bot.webhookUrl,
callbackPayload,
{
'X-Webhook-Signature': signature,
'X-Bot-Id': bot.id.toString(),
},
{
taskId: message.taskId,
organizationId: req.organizationId!,
botId: bot.id,
eventType: 'button_callback',
}
);
res.json({
success: true,
message: 'Callback обработан'
});
} catch (error) {
console.error('Bot callback error:', error);
res.status(500).json({
success: false,
error: 'Ошибка обработки callback'
});
}
}
);
// Bot get task info
app.get('/api/bot/tasks/:id',
authenticateBot,
async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({
success: false,
error: 'Некорректный ID задачи'
});
}
const task = await storage.getTask(taskId, req.organizationId!);
if (!task) {
return res.status(404).json({
success: false,
error: 'Задача не найдена'
});
}
// Получить значения полей
const fieldValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
// Получить сообщения
const messages = await storage.getTaskMessages(taskId, req.organizationId!);
res.json({
success: true,
task: {
...task,
fieldValues,
messages
}
});
} catch (error) {
console.error('Bot get task error:', error);
res.status(500).json({
success: false,
error: 'Ошибка при получении задачи'
});
}
}
);
// Bot update task fields
app.patch('/api/bot/tasks/:id/fields',
authenticateBot,
async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res) => {
try {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) {
return res.status(400).json({
success: false,
error: 'Некорректный ID задачи'
});
}
const task = await storage.getTask(taskId, req.organizationId!);
if (!task) {
return res.status(404).json({
success: false,
error: 'Задача не найдена'
});
}
const { fieldValues } = req.body;
if (!fieldValues || typeof fieldValues !== 'object') {
return res.status(400).json({
success: false,
error: 'fieldValues должен быть объектом'
});
}
// Получить поля формы
const formFields = await storage.getFormFields(task.formId, req.organizationId!);
const fieldMap = new Map(formFields.map(f => [f.code, f]));
// Получить старые значения для аудита
const botOldValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
const botOldValueMap = new Map(botOldValues.map(v => [v.fieldId, v.value]));
// Обновить значения полей
for (const [code, value] of Object.entries(fieldValues)) {
const field = fieldMap.get(code);
if (!field) continue;
try {
await storage.updateTaskFieldValue(taskId, field.id, req.organizationId!, {
value: value as string | number | boolean | null | Record<string, unknown>
});
} catch {
// Если запись не существует, создаём
await storage.createTaskFieldValue({
taskId,
fieldId: field.id,
formId: task.formId,
value: value as string | number | boolean | null | Record<string, unknown>
});
}
}
// Audit log: bot field changes
for (const [code, value] of Object.entries(fieldValues)) {
const field = fieldMap.get(code);
if (!field) continue;
const oldVal = botOldValueMap.get(field.id);
const newVal = value;
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
const newStr = newVal === null || newVal === undefined ? '' : String(newVal);
if (oldStr !== newStr) {
storage.addTaskAuditLog({
taskId,
organizationId: req.organizationId!,
action: 'field.changed',
fieldId: field.id,
fieldName: field.name,
oldValue: oldVal ?? null,
newValue: newVal as string | number | boolean | null,
changedBy: null,
changedByName: 'API / Бот',
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
}
}
// Публикуем событие обновления задачи
const updatedTask = await storage.getTask(taskId, req.organizationId!);
eventBus.publishEvent({
type: 'task_updated',
organizationId: req.organizationId!,
data: {
taskId,
formId: task.formId,
task: updatedTask
}
});
// Web Push: notify all offline task participants
pushTaskUpdated({
taskId,
organizationId: req.organizationId!,
formId: task.formId,
taskTitle: updatedTask?.title ?? task.title,
actorId: 0, // bot update — no actor to exclude
}).catch(() => {});
res.json({
success: true,
message: 'Поля задачи обновлены'
});
} catch (error) {
console.error('Bot update task fields error:', error);
res.status(500).json({
success: false,
error: 'Ошибка при обновлении полей задачи'
});
}
}
);
// Bot gets its messenger conversations (bot_direct only)
app.get('/api/bot/conversations',
authenticateBot,
async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res) => {
try {
const convs = await db
.select()
.from(conversations)
.where(and(
eq(conversations.type, 'bot_direct'),
eq(conversations.botId, req.bot!.id as number),
eq(conversations.organizationId, req.organizationId!),
));
const result = await Promise.all(
convs.map(async (conv) => {
const members = await db
.select({
id: users.id,
firstName: users.firstName,
middleName: users.middleName,
lastName: users.lastName,
email: users.email,
})
.from(conversationMembers)
.innerJoin(users, eq(conversationMembers.userId, users.id))
.where(eq(conversationMembers.conversationId, conv.id));
return {
id: conv.id,
type: conv.type,
createdAt: conv.createdAt,
members,
};
})
);
res.json({ success: true, conversations: result });
} catch (error) {
console.error('Bot get conversations error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении диалогов' });
}
}
);
// Bot gets messages from a messenger conversation (bot_direct only)
app.get('/api/bot/conversations/:id/messages',
authenticateBot,
async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res) => {
try {
const convId = parseInt(req.params.id);
if (isNaN(convId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID чата' });
}
const [conv] = await db
.select()
.from(conversations)
.where(and(
eq(conversations.id, convId),
eq(conversations.organizationId, req.organizationId!),
eq(conversations.type, 'bot_direct'),
eq(conversations.botId, req.bot!.id as number),
));
if (!conv) {
return res.status(404).json({ success: false, error: 'Чат не найден или бот не является его участником' });
}
const limit = Math.min(parseInt((req.query.limit as string) ?? '50'), 100);
const beforeId = req.query.beforeId ? parseInt(req.query.beforeId as string) : null;
const conditions = [
eq(conversationMessages.conversationId, convId),
eq(conversationMessages.isDeleted, false),
];
if (beforeId !== null && !isNaN(beforeId)) {
conditions.push(lt(conversationMessages.id, beforeId));
}
const msgs = await db
.select({
id: conversationMessages.id,
conversationId: conversationMessages.conversationId,
message: conversationMessages.message,
replyToId: conversationMessages.replyToId,
mentionedUserIds: conversationMessages.mentionedUserIds,
attachments: conversationMessages.attachments,
createdAt: conversationMessages.createdAt,
updatedAt: conversationMessages.updatedAt,
isDeleted: conversationMessages.isDeleted,
authorId: conversationMessages.authorId,
botId: conversationMessages.botId,
authorFirstName: users.firstName,
authorLastName: users.lastName,
})
.from(conversationMessages)
.leftJoin(users, eq(conversationMessages.authorId, users.id))
.where(and(...conditions))
.orderBy(desc(conversationMessages.id))
.limit(limit);
// Load bot info for bot messages
const botIds = [...new Set(msgs.filter(m => m.botId).map(m => m.botId!))];
const botInfoMap = new Map<number, { id: number; name: string; avatarUrl: string | null }>();
if (botIds.length > 0) {
const botRows = await db
.select({ id: bots.id, name: bots.name, avatarUrl: bots.avatarUrl })
.from(bots)
.where(inArray(bots.id, botIds));
botRows.forEach(b => botInfoMap.set(b.id, b));
}
const enrichedMsgs = await Promise.all(
msgs.map(async m => {
let replyTo: { id: number; message: string; author: { id: number; firstName: string; lastName: string } } | null = null;
if (m.replyToId) {
const [r] = await db
.select({
id: conversationMessages.id,
message: conversationMessages.message,
authorId: conversationMessages.authorId,
authorFirstName: users.firstName,
authorLastName: users.lastName,
})
.from(conversationMessages)
.leftJoin(users, eq(conversationMessages.authorId, users.id))
.where(and(
eq(conversationMessages.id, m.replyToId),
eq(conversationMessages.conversationId, convId),
));
if (r && r.authorId) {
replyTo = {
id: r.id,
message: r.message,
author: { id: r.authorId, firstName: r.authorFirstName ?? '', lastName: r.authorLastName ?? '' },
};
}
}
const botInfo = m.botId ? botInfoMap.get(m.botId) ?? null : null;
return {
...m,
author: m.authorId
? { id: m.authorId, firstName: m.authorFirstName ?? '', lastName: m.authorLastName ?? '' }
: null,
bot: botInfo,
replyTo,
};
})
);
res.json({ success: true, messages: enrichedMsgs.reverse() });
} catch (error) {
console.error('Bot get messenger messages error:', error);
res.status(500).json({ success: false, error: 'Ошибка при получении сообщений' });
}
}
);
// Bot sends message to a messenger conversation (bot_direct only)
app.post('/api/bot/conversations/:id/messages',
authenticateBot,
async (req: AuthenticatedRequest & { bot?: Record<string, unknown> }, res) => {
try {
const convId = parseInt(req.params.id);
if (isNaN(convId)) {
return res.status(400).json({ success: false, error: 'Некорректный ID чата' });
}
const { message, attachments } = req.body;
if (!message?.trim() && (!attachments || attachments.length === 0)) {
return res.status(400).json({ success: false, error: 'Сообщение или вложения обязательны' });
}
const [conv] = await db
.select()
.from(conversations)
.where(and(
eq(conversations.id, convId),
eq(conversations.organizationId, req.organizationId!),
eq(conversations.type, 'bot_direct'),
eq(conversations.botId, req.bot!.id as number),
));
if (!conv) {
return res.status(404).json({ success: false, error: 'Чат не найден или бот не является его участником' });
}
const [newMsg] = await db
.insert(conversationMessages)
.values({
conversationId: convId,
authorId: null,
botId: req.bot!.id as number,
message: message?.trim() || '',
replyToId: null,
mentionedUserIds: null,
attachments: attachments?.length ? attachments : null,
})
.returning();
const enriched = await enrichMessage(newMsg.id);
const members = await getMembersForSSE(convId, req.organizationId!);
for (const { userId: memberId, orgId: memberOrgId } of members) {
eventBus.publishEvent({
type: 'conv_message_created',
data: { conversationId: convId, message: enriched },
organizationId: memberOrgId,
userId: memberId,
});
}
const botName = (req.bot!.name as string) || 'Бот';
const memberMuteRows = await db
.select({ userId: conversationMembers.userId, mutedAt: conversationMembers.mutedAt, externalOrgId: conversationMembers.externalOrgId })
.from(conversationMembers)
.where(eq(conversationMembers.conversationId, convId));
const memberOrgMap = new Map(
memberMuteRows.map(r => [
r.userId,
{ orgId: r.externalOrgId ?? req.organizationId!, mutedAt: r.mutedAt ?? null },
])
);
notificationService.notifyMessengerMessage({
conversationId: convId,
authorId: -1,
authorName: botName,
chatName: botName,
chatType: 'bot_direct',
messageText: message?.trim() || '',
mentionedUserIds: [],
memberOrgMap,
}).catch(err => console.error('[Bot Messenger Notify] Error:', err));
res.status(201).json({ success: true, message: enriched });
} catch (error) {
console.error('Bot send messenger message error:', error);
res.status(500).json({ success: false, error: 'Ошибка при отправке сообщения' });
}
}
);
}