fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
471
server/routes/chat.messages.routes.ts
Normal file
471
server/routes/chat.messages.routes.ts
Normal file
@@ -0,0 +1,471 @@
|
||||
import { Router } from "express";
|
||||
import { z } from 'zod';
|
||||
import { storage } from "../storage";
|
||||
import { authenticateToken, tryBotServiceToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
|
||||
import { tenantIsolation } from "../middleware/tenant.middleware";
|
||||
import { validateRequest } from "../middleware/validation.middleware";
|
||||
import {
|
||||
createTaskMessageSchema,
|
||||
type NotificationEvent, type Bot,
|
||||
} from "@shared/schema";
|
||||
import { notificationService, EVENT_TYPES } from "../services/notification.service";
|
||||
import { webhookService } from "../services/webhook.service";
|
||||
import { generateBotServiceToken } from "../utils/jwt";
|
||||
import { sendWebhook } from "../utils/webhook";
|
||||
import { eventBus } from "./shared";
|
||||
import { buildMessageText, upsertEmbedding } from "../services/embedding.service";
|
||||
import { handleAiBotMention, checkBotAccess } from "../services/ai-bot.service";
|
||||
import { db } from "../db";
|
||||
import { fileUploads } from "@shared/schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
export function registerChatMessageRoutes(router: Router): void {
|
||||
// Get task messages
|
||||
router.get('/api/tasks/:id/messages',
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const taskId = parseInt(req.params.id);
|
||||
if (isNaN(taskId)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректный ID задачи' });
|
||||
}
|
||||
|
||||
const taskRolesForRead = await storage.getTaskRoles(taskId, req.organizationId!);
|
||||
if (taskRolesForRead.length > 0) {
|
||||
const hasAccess = await storage.canUserAccessTask(
|
||||
taskId, req.user!.id, req.organizationId!, req.user!.appRole
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
|
||||
}
|
||||
}
|
||||
|
||||
const messages = await storage.getTaskMessages(taskId, req.organizationId!);
|
||||
res.json({ success: true, messages });
|
||||
} catch (error) {
|
||||
console.error('Get task messages error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при загрузке сообщений' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create task message
|
||||
router.post('/api/tasks/:id/messages',
|
||||
tryBotServiceToken,
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
validateRequest(createTaskMessageSchema.omit({ taskId: true })),
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const taskId = parseInt(req.params.id);
|
||||
if (isNaN(taskId)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректный ID задачи' });
|
||||
}
|
||||
|
||||
const { message: msgBody, attachments: msgAttachments } = req.body;
|
||||
if (!msgBody?.trim() && (!msgAttachments || msgAttachments.length === 0)) {
|
||||
return res.status(400).json({ success: false, error: 'Сообщение или вложения обязательны' });
|
||||
}
|
||||
|
||||
const task = await storage.getTask(taskId, req.organizationId!);
|
||||
if (!task) {
|
||||
return res.status(404).json({ success: false, error: 'Задача не найдена' });
|
||||
}
|
||||
|
||||
{
|
||||
const taskRolesForGuard = await storage.getTaskRoles(taskId, req.organizationId!);
|
||||
if (taskRolesForGuard.length > 0) {
|
||||
const hasAccess = await storage.canUserAccessTask(
|
||||
taskId, req.user!.id, req.organizationId!, req.user!.appRole
|
||||
);
|
||||
if (!hasAccess) {
|
||||
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let replyToMessage = null;
|
||||
if (req.body.replyToMessageId) {
|
||||
const existingMessages = await storage.getTaskMessages(taskId, req.organizationId!);
|
||||
replyToMessage = existingMessages.find(m => m.id === req.body.replyToMessageId);
|
||||
if (!replyToMessage) {
|
||||
return res.status(400).json({ success: false, error: 'Сообщение для ответа не найдено' });
|
||||
}
|
||||
}
|
||||
|
||||
let mentionedUserIds: number[] = [];
|
||||
if (req.body.mentionedUserIds && req.body.mentionedUserIds.length > 0) {
|
||||
const orgUsers = await storage.getUsersByOrganization(req.organizationId!);
|
||||
const orgUserIds = orgUsers.map(user => user.id);
|
||||
mentionedUserIds = req.body.mentionedUserIds.filter((id: number) =>
|
||||
orgUserIds.includes(id) && id !== req.user!.id
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent bot response loops: skip bot processing when:
|
||||
// 1. messageType is 'bot' or 'system' (messages authored by the bot service)
|
||||
// 2. authorId is null in the payload (another indicator of a bot/system message)
|
||||
// 3. Request was authenticated via a bot-service JWT (req.isBotToken = true)
|
||||
const isBotOrSystemMessage =
|
||||
req.body.messageType === 'bot' ||
|
||||
req.body.messageType === 'system' ||
|
||||
req.body.authorId === null ||
|
||||
req.isBotToken === true;
|
||||
|
||||
let mentionedBotIds: number[] = [];
|
||||
let mentionedBotsMap = new Map<number, Bot>();
|
||||
if (!isBotOrSystemMessage && req.body.mentionedBotIds && req.body.mentionedBotIds.length > 0) {
|
||||
const orgBots = await storage.getBotsByOrganization(req.organizationId!);
|
||||
const activeBots = orgBots.filter(b => b.isActive);
|
||||
activeBots.forEach(bot => mentionedBotsMap.set(bot.id, bot));
|
||||
const orgBotIds = activeBots.map(bot => bot.id);
|
||||
mentionedBotIds = req.body.mentionedBotIds.filter((id: number) => orgBotIds.includes(id));
|
||||
}
|
||||
|
||||
const messageData = {
|
||||
taskId,
|
||||
formId: task.formId,
|
||||
authorId: req.user!.id,
|
||||
replyToMessageId: req.body.replyToMessageId || null,
|
||||
message: req.body.message || '',
|
||||
messageType: req.body.messageType || 'comment',
|
||||
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : null,
|
||||
attachments: req.body.attachments?.length ? req.body.attachments : null,
|
||||
};
|
||||
|
||||
const createdMessage = await storage.createTaskMessage(messageData, req.organizationId!);
|
||||
|
||||
// Link file uploads to this task
|
||||
if (createdMessage.attachments && createdMessage.attachments.length > 0) {
|
||||
try {
|
||||
for (const att of createdMessage.attachments) {
|
||||
const fileKey = att.url.startsWith('/api/files/') ? att.url.replace('/api/files/', '') : (att.url.startsWith('/uploads/') ? att.url.replace('/uploads/', '') : att.url);
|
||||
await db.update(fileUploads).set({ taskId }).where(eq(fileUploads.fileKey, fileKey));
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[Chat] Failed to link file uploads to task:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (mentionedUserIds.length > 0) {
|
||||
try {
|
||||
await Promise.all(
|
||||
mentionedUserIds.map(uid =>
|
||||
storage.upsertTaskUserAccess(taskId, uid, req.organizationId!, 'mention')
|
||||
)
|
||||
);
|
||||
// Notify subscribers that the task has changed (new mention access)
|
||||
eventBus.publishEvent({
|
||||
type: 'task_updated',
|
||||
data: { taskId: taskId, formId: task.formId, task },
|
||||
organizationId: req.organizationId!,
|
||||
taskId: taskId
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('upsertTaskUserAccess error for mention:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (mentionedBotIds.length > 0) {
|
||||
const taskFieldValues = await storage.getTaskFieldValues(taskId, req.organizationId!);
|
||||
const form = await storage.getForm(task.formId, req.organizationId!);
|
||||
const taskWithFields = { ...task, fieldValues: taskFieldValues };
|
||||
|
||||
for (const botId of mentionedBotIds) {
|
||||
const bot = mentionedBotsMap.get(botId);
|
||||
if (!bot) continue;
|
||||
|
||||
if (bot.type === 'ai_assistant') {
|
||||
if (!checkBotAccess(bot, req.user!)) {
|
||||
try {
|
||||
await storage.createTaskMessage({
|
||||
taskId,
|
||||
formId: task.formId,
|
||||
authorId: null,
|
||||
botId: bot.id,
|
||||
replyToMessageId: null,
|
||||
message: `⛔ У вас нет доступа к боту ${bot.name}`,
|
||||
messageType: 'system',
|
||||
mentionedUserIds: null,
|
||||
attachments: null,
|
||||
}, req.organizationId!);
|
||||
} catch (err) {
|
||||
console.error(`[AI-Bot] Failed to post denial message for bot ${bot.id}:`, err);
|
||||
}
|
||||
eventBus.publishEvent({
|
||||
type: 'message_created',
|
||||
data: { taskId },
|
||||
organizationId: req.organizationId,
|
||||
taskId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
handleAiBotMention({
|
||||
bot,
|
||||
task,
|
||||
message: createdMessage.message,
|
||||
attachments: createdMessage.attachments || undefined,
|
||||
user: req.user!,
|
||||
organizationId: req.organizationId!,
|
||||
}).then(() => {
|
||||
eventBus.publishEvent({
|
||||
type: 'message_created',
|
||||
data: { taskId },
|
||||
organizationId: req.organizationId,
|
||||
taskId,
|
||||
});
|
||||
}).catch(err => console.error(`[AI-Bot] Error for bot ${bot.name}:`, err));
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bot.webhookUrl) {
|
||||
try {
|
||||
const botAccessToken = generateBotServiceToken(bot.id, req.organizationId!);
|
||||
const webhookPayload = {
|
||||
event: 'bot_mentioned',
|
||||
timestamp: new Date().toISOString(),
|
||||
organizationId: req.organizationId,
|
||||
data: {
|
||||
task: taskWithFields,
|
||||
form: form ? { id: form.id, name: form.name } : null,
|
||||
message: {
|
||||
id: createdMessage.id,
|
||||
text: createdMessage.message,
|
||||
authorId: req.user!.id,
|
||||
authorName: `${req.user!.firstName} ${req.user!.middleName || ''} ${req.user!.lastName}`.trim()
|
||||
},
|
||||
bot: { id: bot.id, name: bot.name, accessToken: botAccessToken }
|
||||
}
|
||||
};
|
||||
sendWebhook(
|
||||
bot.webhookUrl,
|
||||
webhookPayload,
|
||||
{
|
||||
'X-Bot-Id': bot.id.toString(),
|
||||
'X-Organization-Id': req.organizationId!.toString()
|
||||
},
|
||||
{ taskId, organizationId: req.organizationId!, botId: bot.id, eventType: 'bot_mentioned' }
|
||||
).catch(err => console.error(`Webhook to bot ${bot.name} failed:`, err));
|
||||
} catch (err) {
|
||||
console.error(`Error sending webhook to bot ${bot.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eventBus.publishEvent({
|
||||
type: 'message_created',
|
||||
data: { taskId: taskId, message: createdMessage },
|
||||
organizationId: req.organizationId,
|
||||
taskId: taskId
|
||||
});
|
||||
|
||||
const notificationEvent: NotificationEvent = {
|
||||
type: replyToMessage ? EVENT_TYPES.TASK_COMMENT_REPLIED : EVENT_TYPES.TASK_COMMENT_CREATED,
|
||||
organizationId: req.organizationId!,
|
||||
triggeredBy: req.user!.id,
|
||||
taskId: taskId,
|
||||
formId: task.formId,
|
||||
messageId: createdMessage.id,
|
||||
payload: {
|
||||
message: createdMessage.message.length > 100
|
||||
? createdMessage.message.substring(0, 100) + '...'
|
||||
: createdMessage.message,
|
||||
taskTitle: task.title,
|
||||
authorName: `${req.user!.firstName} ${req.user!.middleName || ''} ${req.user!.lastName}`.trim(),
|
||||
originalAuthorId: replyToMessage?.authorId,
|
||||
},
|
||||
mentionedUserIds: mentionedUserIds,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
notificationService.processEvent(notificationEvent).then(notifiedUserIds => {
|
||||
notifiedUserIds.forEach(userId => {
|
||||
eventBus.publishEvent({
|
||||
type: 'notification',
|
||||
data: { type: notificationEvent.type, taskId, messageId: createdMessage.id },
|
||||
organizationId: req.organizationId!,
|
||||
userId: userId
|
||||
});
|
||||
});
|
||||
}).catch(err => console.error('Notification processing error:', err));
|
||||
|
||||
if (messageData.messageType === 'comment') {
|
||||
webhookService.dispatchComment(
|
||||
req.organizationId!, taskId, task.formId, createdMessage, req.user!.id
|
||||
).catch(err => console.error('Webhook dispatch error:', err));
|
||||
}
|
||||
|
||||
if (messageData.messageType === 'comment') {
|
||||
(async () => {
|
||||
try {
|
||||
const users = await storage.getUsersByOrganization(req.organizationId!);
|
||||
const usersMap = new Map(users.map(u => [u.id, u]));
|
||||
const authorUser = req.user?.id ? usersMap.get(req.user.id) : null;
|
||||
const authorName = authorUser
|
||||
? `${authorUser.firstName ?? ''} ${authorUser.middleName ?? ''} ${authorUser.lastName ?? ''}`.trim() || authorUser.email
|
||||
: 'Пользователь';
|
||||
const content = buildMessageText(createdMessage, authorName, task.title);
|
||||
await upsertEmbedding(req.organizationId!, 'task_message', createdMessage.id, content, {
|
||||
taskId, formId: task.formId, authorId: req.user?.id,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[RAG] indexMessage error:', err);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (req.user?.id) {
|
||||
storage.recordTaskInteractionAuto(taskId, req.user.id, req.organizationId!)
|
||||
.catch(err => console.error('recordTaskInteraction error:', err));
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Сообщение добавлено',
|
||||
taskMessage: createdMessage
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create task message error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при создании сообщения' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update task message
|
||||
router.put('/api/messages/:id',
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const messageId = parseInt(req.params.id);
|
||||
if (isNaN(messageId)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректный ID сообщения' });
|
||||
}
|
||||
|
||||
const { message } = req.body;
|
||||
if (!message || typeof message !== 'string' || message.trim().length === 0) {
|
||||
return res.status(400).json({ success: false, error: 'Текст сообщения обязателен' });
|
||||
}
|
||||
|
||||
const existingMessage = await storage.getTaskMessage(messageId, req.organizationId!);
|
||||
if (!existingMessage) {
|
||||
return res.status(404).json({ success: false, error: 'Сообщение не найдено' });
|
||||
}
|
||||
|
||||
if (existingMessage.authorId !== req.user!.id && req.user!.appRole !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: 'Нет прав для редактирования этого сообщения' });
|
||||
}
|
||||
|
||||
const updatedMessage = await storage.updateTaskMessage(
|
||||
messageId, req.organizationId!, { message: message.trim() }
|
||||
);
|
||||
|
||||
res.json({ success: true, message: 'Сообщение обновлено', taskMessage: updatedMessage });
|
||||
} catch (error) {
|
||||
console.error('Update task message error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при обновлении сообщения' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Delete task message
|
||||
router.delete('/api/messages/:id',
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const messageId = parseInt(req.params.id);
|
||||
if (isNaN(messageId)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректный ID сообщения' });
|
||||
}
|
||||
|
||||
const existingMessage = await storage.getTaskMessage(messageId, req.organizationId!);
|
||||
if (!existingMessage) {
|
||||
return res.status(404).json({ success: false, error: 'Сообщение не найдено' });
|
||||
}
|
||||
|
||||
if (existingMessage.authorId !== req.user!.id && req.user!.appRole !== 'admin') {
|
||||
return res.status(403).json({ success: false, error: 'Нет прав для удаления этого сообщения' });
|
||||
}
|
||||
|
||||
await storage.deleteTaskMessage(messageId, req.organizationId!);
|
||||
res.json({ success: true, message: 'Сообщение удалено' });
|
||||
} catch (error) {
|
||||
console.error('Delete task message error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при удалении сообщения' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Mark message as read
|
||||
router.post('/api/messages/:id/read',
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const messageId = parseInt(req.params.id);
|
||||
if (isNaN(messageId)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректный ID сообщения' });
|
||||
}
|
||||
|
||||
const targetMessage = await storage.getTaskMessage(messageId, req.organizationId!);
|
||||
if (!targetMessage) {
|
||||
return res.status(404).json({ success: false, error: 'Сообщение не найдено' });
|
||||
}
|
||||
|
||||
const messageRead = await storage.markMessageAsRead(
|
||||
messageId, req.user!.id, req.organizationId!
|
||||
);
|
||||
|
||||
if (targetMessage && targetMessage.authorId !== req.user!.id) {
|
||||
eventBus.publishEvent({
|
||||
type: 'message_read',
|
||||
data: {
|
||||
messageId: messageId,
|
||||
taskId: targetMessage.taskId,
|
||||
readerId: req.user!.id,
|
||||
readerName: `${req.user!.firstName} ${req.user!.middleName || ''} ${req.user!.lastName}`.trim(),
|
||||
authorId: targetMessage.authorId
|
||||
},
|
||||
organizationId: req.organizationId!,
|
||||
userId: targetMessage.authorId ?? undefined
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Сообщение отмечено как прочитанное', messageRead });
|
||||
} catch (error) {
|
||||
console.error('Mark message as read error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при отметке сообщения как прочитанного' });
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get message read receipts
|
||||
router.get('/api/messages/:id/reads',
|
||||
authenticateToken,
|
||||
tenantIsolation,
|
||||
async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const messageId = parseInt(req.params.id);
|
||||
if (isNaN(messageId)) {
|
||||
return res.status(400).json({ success: false, error: 'Некорректный ID сообщения' });
|
||||
}
|
||||
|
||||
const message = await storage.getTaskMessage(messageId, req.organizationId!);
|
||||
if (!message) {
|
||||
return res.status(404).json({ success: false, error: 'Сообщение не найдено' });
|
||||
}
|
||||
|
||||
const reads = await storage.getMessageReads(messageId, req.organizationId!);
|
||||
res.json({ success: true, reads });
|
||||
} catch (error) {
|
||||
console.error('Get message reads error:', error);
|
||||
res.status(500).json({ success: false, error: 'Ошибка при получении статуса прочтения' });
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user