Files
iistwin/server/routes/chat.messages.routes.ts

462 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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 afterId = req.query.afterId ? parseInt(req.query.afterId as string) : undefined;
const messages = await storage.getTaskMessages(taskId, req.organizationId!, afterId);
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!)) {
let denialMessage;
try {
denialMessage = 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);
}
if (denialMessage) {
eventBus.publishEvent({
type: 'message_created',
data: {
taskId,
message: {
...denialMessage,
bot: bot ? { id: bot.id, name: bot.name, avatarUrl: bot.avatarUrl ?? null } : null,
},
},
organizationId: req.organizationId,
taskId,
});
}
continue;
}
handleAiBotMention({
bot,
task,
message: createdMessage.message,
attachments: createdMessage.attachments || undefined,
user: req.user!,
organizationId: req.organizationId!,
}).catch(err => console.error(`[AI-Bot] Error for bot ${bot.name}:`, err));
// postBotReply внутри handleAiBotMention сам публикует message_created с полным сообщением
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') {
storage.enqueueEmbedding(req.organizationId!, 'task_message', createdMessage.id, 'upsert')
.catch(err => console.error('[RAG] enqueue message embedding 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: 'Ошибка при получении статуса прочтения' });
}
}
);
}