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,465 @@
import { Router } from "express";
import { z } from "zod";
import { db, openTenantCtx } from "../db";
import { conversations, conversationMembers, conversationMessages, users, bots, type Bot } from "@shared/schema";
import { eq, and, lt, desc, sql, inArray } from "drizzle-orm";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { eventBus } from "./shared";
import { isMember, enrichMessage, getMembersForSSE } from "./messenger.helpers";
import { formatUserName } from "../utils/formatUserName";
import { notificationService } from "../services/notification.service";
import { handleAiBotDirectMessage } from "../services/ai-bot.service";
import { checkBotAccess } from "../services/ai-bot.service";
export function registerMessengerMessageRoutes(router: Router): void {
// GET /api/messenger/conversations/:id/messages
router.get("/api/messenger/conversations/:id/messages",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const convId = parseInt(req.params.id);
if (isNaN(convId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
if (!await isMember(convId, userId)) {
return res.status(403).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,
authorMiddleName: users.middleName,
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; middleName: 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,
authorMiddleName: users.middleName,
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 ?? '', middleName: r.authorMiddleName ?? '', lastName: r.authorLastName ?? '' },
};
}
}
const botInfo = m.botId ? botInfoMap.get(m.botId) ?? null : null;
return {
...m,
author: m.authorId
? { id: m.authorId, firstName: m.authorFirstName ?? '', middleName: m.authorMiddleName ?? '', lastName: m.authorLastName ?? '' }
: null,
bot: botInfo,
replyTo,
};
})
);
res.json({ success: true, messages: enrichedMsgs.reverse() });
} catch (err) {
console.error("get messages error:", err);
res.status(500).json({ success: false, error: "Ошибка при загрузке сообщений" });
}
}
);
// POST /api/messenger/conversations/:id/messages — send message
const sendSchema = z.object({
message: z.string().max(10000),
replyToId: z.number().int().positive().optional(),
mentionedUserIds: z.array(z.number().int().positive()).optional(),
attachments: z.array(z.object({
url: z.string(),
name: z.string(),
size: z.number().default(0),
mimeType: z.string().optional(),
})).optional(),
});
router.post("/api/messenger/conversations/:id/messages",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const convId = parseInt(req.params.id);
if (isNaN(convId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
if (!await isMember(convId, userId)) {
return res.status(403).json({ success: false, error: "Нет доступа" });
}
const parsed = sendSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ success: false, error: "Некорректные данные", details: parsed.error.issues });
}
const { message, replyToId, mentionedUserIds, attachments } = parsed.data;
if (!message.trim() && (!attachments || attachments.length === 0)) {
return res.status(400).json({ success: false, error: "Сообщение или вложения обязательны" });
}
const [conv] = await db
.select({ organizationId: conversations.organizationId, type: conversations.type, name: conversations.name, botId: conversations.botId })
.from(conversations)
.where(eq(conversations.id, convId));
const orgId = conv?.organizationId ?? req.organizationId!;
const [newMsg] = await db
.insert(conversationMessages)
.values({
conversationId: convId,
authorId: userId,
message: message.trim(),
replyToId: replyToId ?? null,
mentionedUserIds: mentionedUserIds?.length ? mentionedUserIds : null,
attachments: attachments?.length ? attachments : null,
})
.returning();
const enriched = await enrichMessage(newMsg.id);
const members = await getMembersForSSE(convId, orgId);
const [author] = await db
.select({ firstName: users.firstName, middleName: users.middleName, lastName: users.lastName })
.from(users)
.where(eq(users.id, userId));
const authorName = author ? formatUserName(author) : 'Пользователь';
const memberMuteRows = await db
.select({ userId: conversationMembers.userId, mutedAt: conversationMembers.mutedAt, externalOrgId: conversationMembers.externalOrgId })
.from(conversationMembers)
.where(eq(conversationMembers.conversationId, convId));
const chatName = conv?.type === 'group' && conv.name
? conv.name
: authorName;
const memberOrgMap = new Map(
memberMuteRows.map(r => [
r.userId,
{ orgId: r.externalOrgId ?? orgId, mutedAt: r.mutedAt ?? null },
])
);
for (const { userId: memberId, orgId: memberOrgId } of members) {
eventBus.publishEvent({
type: "conv_message_created",
data: { conversationId: convId, message: enriched },
organizationId: memberOrgId,
userId: memberId,
});
if (memberId !== userId) {
await db
.update(conversationMembers)
.set({ reactionUnreadCount: sql`COALESCE(reaction_unread_count, 0)` })
.where(and(
eq(conversationMembers.conversationId, convId),
eq(conversationMembers.userId, memberId),
));
}
}
notificationService.notifyMessengerMessage({
conversationId: convId,
authorId: userId,
authorName,
chatName,
chatType: (conv?.type ?? 'direct') as 'direct' | 'group',
messageText: message.trim(),
mentionedUserIds: mentionedUserIds ?? [],
memberOrgMap,
}).catch(err => console.error('[Messenger Notify] Error:', err));
res.status(201).json({ success: true, message: enriched });
// Trigger AI bot response for bot_direct conversations (after response sent to user)
if (conv?.type === 'bot_direct' && conv.botId) {
const botId = conv.botId;
setImmediate(async () => {
try {
const handle = await openTenantCtx(orgId);
try {
await handle.run(async () => {
const [bot] = await db
.select()
.from(bots)
.where(eq(bots.id, botId));
if (!bot || !bot.isActive) return;
if (bot.type !== 'ai_assistant') return;
const typedBot = bot as Bot;
if (!checkBotAccess(typedBot, req.user!)) {
const [denyMsg] = await db
.insert(conversationMessages)
.values({
conversationId: convId,
authorId: null,
botId: bot.id,
message: `⛔ У вас нет доступа к боту ${bot.name}`,
replyToId: null,
mentionedUserIds: null,
attachments: null,
})
.returning();
const enrichedDeny = await enrichMessage(denyMsg.id);
for (const { userId: memberId, orgId: memberOrgId } of members) {
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedDeny }, organizationId: memberOrgId, userId: memberId });
}
notificationService.notifyMessengerMessage({
conversationId: convId,
authorId: -1,
authorName: bot.name,
chatName: bot.name,
chatType: 'bot_direct',
messageText: `⛔ У вас нет доступа к боту ${bot.name}`,
mentionedUserIds: [],
memberOrgMap,
}).catch(err => console.error('[Bot Deny Notify] Error:', err));
return;
}
await handleAiBotDirectMessage({
bot: typedBot,
conversationId: convId,
message: message.trim(),
user: req.user!,
organizationId: orgId,
});
const latestBotMsg = await db
.select({ id: conversationMessages.id })
.from(conversationMessages)
.where(and(
eq(conversationMessages.conversationId, convId),
eq(conversationMessages.botId, botId),
))
.orderBy(desc(conversationMessages.id))
.limit(1);
if (latestBotMsg[0]) {
const enrichedBot = await enrichMessage(latestBotMsg[0].id);
for (const { userId: memberId, orgId: memberOrgId } of members) {
eventBus.publishEvent({ type: "conv_message_created", data: { conversationId: convId, message: enrichedBot }, organizationId: memberOrgId, userId: memberId });
}
notificationService.notifyMessengerMessage({
conversationId: convId,
authorId: -1,
authorName: bot.name,
chatName: bot.name,
chatType: 'bot_direct',
messageText: (enrichedBot as any).message ?? '',
mentionedUserIds: [],
memberOrgMap,
}).catch(err => console.error('[Bot Reply Notify] Error:', err));
}
});
} finally {
await handle.release().catch(() => {});
}
} catch (err) {
console.error('[Bot Direct] Error handling bot response:', err);
}
});
}
} catch (err) {
console.error("send message error:", err);
res.status(500).json({ success: false, error: "Ошибка при отправке" });
}
}
);
// PATCH /api/messenger/messages/:id — edit message
router.patch("/api/messenger/messages/:id",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const msgId = parseInt(req.params.id);
if (isNaN(msgId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
const [msg] = await db
.select({ authorId: conversationMessages.authorId, conversationId: conversationMessages.conversationId, isDeleted: conversationMessages.isDeleted })
.from(conversationMessages)
.where(eq(conversationMessages.id, msgId));
if (!msg || msg.isDeleted) return res.status(404).json({ success: false, error: "Сообщение не найдено" });
if (msg.authorId !== userId) return res.status(403).json({ success: false, error: "Нельзя редактировать чужие сообщения" });
const { message } = req.body;
if (typeof message !== "string" || !message.trim()) {
return res.status(400).json({ success: false, error: "Текст не может быть пустым" });
}
await db
.update(conversationMessages)
.set({ message: message.trim(), updatedAt: new Date() })
.where(eq(conversationMessages.id, msgId));
const enriched = await enrichMessage(msgId);
const [conv] = await db
.select({ organizationId: conversations.organizationId })
.from(conversations)
.where(eq(conversations.id, msg.conversationId));
const members = await getMembersForSSE(msg.conversationId, conv?.organizationId ?? req.organizationId!);
for (const { userId: memberId, orgId } of members) {
eventBus.publishEvent({ type: "conv_message_updated", data: { conversationId: msg.conversationId, message: enriched }, organizationId: orgId, userId: memberId });
}
res.json({ success: true, message: enriched });
} catch (err) {
console.error("edit message error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// DELETE /api/messenger/messages/:id — soft-delete message
router.delete("/api/messenger/messages/:id",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const msgId = parseInt(req.params.id);
if (isNaN(msgId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
const [msg] = await db
.select({ authorId: conversationMessages.authorId, conversationId: conversationMessages.conversationId, isDeleted: conversationMessages.isDeleted })
.from(conversationMessages)
.where(eq(conversationMessages.id, msgId));
if (!msg || msg.isDeleted) return res.status(404).json({ success: false, error: "Сообщение не найдено" });
const isAdmin = req.user?.appRole === "admin";
if (msg.authorId !== userId && !isAdmin) {
return res.status(403).json({ success: false, error: "Нельзя удалить чужое сообщение" });
}
await db
.update(conversationMessages)
.set({ isDeleted: true, message: "", updatedAt: new Date() })
.where(eq(conversationMessages.id, msgId));
const [conv] = await db
.select({ organizationId: conversations.organizationId })
.from(conversations)
.where(eq(conversations.id, msg.conversationId));
const members = await getMembersForSSE(msg.conversationId, conv?.organizationId ?? req.organizationId!);
for (const { userId: memberId, orgId } of members) {
eventBus.publishEvent({ type: "conv_message_deleted", data: { id: msgId, conversationId: msg.conversationId }, organizationId: orgId, userId: memberId });
}
res.json({ success: true });
} catch (err) {
console.error("delete message error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// POST /api/messenger/conversations/:id/read — mark conversation as read
router.post("/api/messenger/conversations/:id/read",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const convId = parseInt(req.params.id);
if (isNaN(convId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
if (!await isMember(convId, userId)) {
return res.status(403).json({ success: false, error: "Нет доступа" });
}
await db
.update(conversationMembers)
.set({ lastReadAt: new Date(), reactionUnreadCount: 0 })
.where(and(
eq(conversationMembers.conversationId, convId),
eq(conversationMembers.userId, userId),
));
const [conv] = await db
.select({ organizationId: conversations.organizationId })
.from(conversations)
.where(eq(conversations.id, convId));
const members = await getMembersForSSE(convId, conv?.organizationId ?? req.organizationId!);
for (const { userId: memberId, orgId } of members) {
eventBus.publishEvent({
type: "conv_read",
data: { conversationId: convId, userId },
organizationId: orgId,
userId: memberId,
});
}
res.json({ success: true });
} catch (err) {
console.error("mark read error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
}