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,677 @@
import { Router } from "express";
import { z } from "zod";
import { db } from "../db";
import { conversations, conversationMembers, conversationMessages, users, orgConnections, bots } from "@shared/schema";
import { eq, and, inArray, desc, sql, or } from "drizzle-orm";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { eventBus } from "./shared";
import { isMember, getConversationWithMeta } from "./messenger.helpers";
export function registerMessengerConversationRoutes(router: Router): void {
// GET /api/messenger/search
router.get("/api/messenger/search",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const orgId = req.organizationId!;
const q = ((req.query.q as string) ?? "").trim();
if (!q) return res.json({ success: true, people: [], groups: [], external: [], bots: [] });
const lq = `%${q.toLowerCase()}%`;
const people = await db
.select({ id: users.id, firstName: users.firstName, middleName: users.middleName, lastName: users.lastName, position: users.position })
.from(users)
.where(and(
eq(users.organizationId, orgId),
sql`users.is_active = true`,
sql`users.id != ${userId}`,
or(
sql`lower(users.first_name || ' ' || users.middle_name || ' ' || users.last_name) like ${lq}`,
sql`lower(users.last_name || ' ' || users.first_name || ' ' || users.middle_name) like ${lq}`,
),
))
.limit(20);
const myConvIds = await db
.select({ convId: conversationMembers.conversationId })
.from(conversationMembers)
.where(eq(conversationMembers.userId, userId));
let groups: { id: number; name: string }[] = [];
if (myConvIds.length) {
groups = await db
.select({ id: conversations.id, name: conversations.name })
.from(conversations)
.where(and(
inArray(conversations.id, myConvIds.map(r => r.convId)),
eq(conversations.type, "group"),
sql`lower(conversations.name) like ${lq}`,
))
.limit(10) as { id: number; name: string }[];
}
const activeConns = await db
.select()
.from(orgConnections)
.where(and(
eq(orgConnections.status, "active"),
or(
eq(orgConnections.initiatorOrgId, orgId),
eq(orgConnections.partnerOrgId!, orgId),
),
));
let external: { id: number; firstName: string; lastName: string; position?: string | null; organizationId: number; connectionId: number }[] = [];
if (activeConns.length) {
const partnerOrgIds = activeConns.map(c =>
c.initiatorOrgId === orgId ? c.partnerOrgId! : c.initiatorOrgId
);
const extUsers = await db
.select({ id: users.id, firstName: users.firstName, middleName: users.middleName, lastName: users.lastName, position: users.position, organizationId: users.organizationId })
.from(users)
.where(and(
inArray(users.organizationId, partnerOrgIds),
sql`users.is_active = true`,
or(
sql`lower(users.first_name || ' ' || users.middle_name || ' ' || users.last_name) like ${lq}`,
sql`lower(users.last_name || ' ' || users.first_name || ' ' || users.middle_name) like ${lq}`,
),
))
.limit(20);
external = extUsers.map(u => {
const conn = activeConns.find(c =>
(c.initiatorOrgId === orgId && c.partnerOrgId === u.organizationId) ||
(c.partnerOrgId === orgId && c.initiatorOrgId === u.organizationId)
)!;
return { ...u, connectionId: conn.id };
});
}
// Search bots accessible to this user
const orgBots = await db
.select({ id: bots.id, name: bots.name, login: bots.login, avatarUrl: bots.avatarUrl, accessPolicy: bots.accessPolicy })
.from(bots)
.where(and(
eq(bots.organizationId, orgId),
eq(bots.isActive, true),
or(
sql`lower(${bots.name}) like ${lq}`,
sql`lower(${bots.login}) like ${lq}`,
),
))
.limit(10);
// Filter by access policy using user from request
const accessibleBots = orgBots.filter(bot => {
const policy = bot.accessPolicy as { mode?: string; roles?: string[]; userIds?: number[] } | null;
if (!policy || policy.mode === 'all') return true;
if (policy.mode === 'roles') return (policy.roles ?? []).includes(req.user!.appRole);
if (policy.mode === 'users') return (policy.userIds ?? []).includes(req.user!.id);
return false;
}).map(({ accessPolicy: _, ...b }) => b);
res.json({ success: true, people, groups, external, bots: accessibleBots });
} catch (err) {
console.error("messenger search error:", err);
res.status(500).json({ success: false, error: "Ошибка поиска" });
}
}
);
// GET /api/messenger/conversations
router.get("/api/messenger/conversations",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const myConvIds = await db
.select({ convId: conversationMembers.conversationId })
.from(conversationMembers)
.where(eq(conversationMembers.userId, userId));
if (!myConvIds.length) return res.json({ success: true, conversations: [] });
const ids = myConvIds.map(r => r.convId);
const result = await Promise.all(ids.map(id => getConversationWithMeta(id, userId)));
const sorted = result
.filter(Boolean)
.sort((a, b) => {
const aTime = a!.lastMessage?.createdAt ?? a!.createdAt ?? new Date(0);
const bTime = b!.lastMessage?.createdAt ?? b!.createdAt ?? new Date(0);
return new Date(bTime).getTime() - new Date(aTime).getTime();
});
res.json({ success: true, conversations: sorted });
} catch (err) {
console.error("messenger conversations error:", err);
res.status(500).json({ success: false, error: "Ошибка при загрузке диалогов" });
}
}
);
// GET /api/messenger/unread-count
router.get("/api/messenger/unread-count",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const myConvIds = await db
.select({ convId: conversationMembers.conversationId, lastReadAt: conversationMembers.lastReadAt, reactionUnreadCount: conversationMembers.reactionUnreadCount })
.from(conversationMembers)
.where(eq(conversationMembers.userId, userId));
let total = 0;
for (const { convId, lastReadAt, reactionUnreadCount } of myConvIds) {
const condition = lastReadAt
? and(
eq(conversationMessages.conversationId, convId),
eq(conversationMessages.isDeleted, false),
sql`${conversationMessages.createdAt} > ${lastReadAt}`,
sql`(${conversationMessages.authorId} IS NULL OR ${conversationMessages.authorId} != ${userId})`,
)
: and(
eq(conversationMessages.conversationId, convId),
eq(conversationMessages.isDeleted, false),
sql`(${conversationMessages.authorId} IS NULL OR ${conversationMessages.authorId} != ${userId})`,
);
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(conversationMessages)
.where(condition);
total += (row?.count ?? 0) + (reactionUnreadCount ?? 0);
}
res.json({ success: true, unreadCount: total });
} catch (err) {
console.error("unread-count error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// POST /api/messenger/conversations — create or find conversation
const createConvSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("direct"), targetUserId: z.number().int().positive() }),
z.object({ type: z.literal("group"), name: z.string().min(1).max(200), memberIds: z.array(z.number().int().positive()).min(1) }),
z.object({ type: z.literal("external_direct"), connectionId: z.number().int().positive(), targetUserId: z.number().int().positive() }),
z.object({ type: z.literal("external_group"), connectionId: z.number().int().positive(), name: z.string().min(1).max(200), memberIds: z.array(z.number().int().positive()).min(1) }),
z.object({ type: z.literal("bot_direct"), botId: z.number().int().positive() }),
]);
router.post("/api/messenger/conversations",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const orgId = req.organizationId!;
const parsed = createConvSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ success: false, error: "Некорректный запрос", details: parsed.error.issues });
}
const body = parsed.data;
if (body.type === "direct") {
const targetUserId = body.targetUserId;
if (targetUserId === userId) {
return res.status(400).json({ success: false, error: "Нельзя создать диалог с самим собой" });
}
const [targetUser] = await db
.select({ id: users.id })
.from(users)
.where(and(eq(users.id, targetUserId), eq(users.organizationId, orgId)));
if (!targetUser) {
return res.status(404).json({ success: false, error: "Пользователь не найден" });
}
interface DirectConvRow { conversation_id: number }
const existing = await db.execute(sql`
SELECT cm1.conversation_id
FROM conversation_members cm1
JOIN conversation_members cm2 ON cm1.conversation_id = cm2.conversation_id
JOIN conversations c ON c.id = cm1.conversation_id
WHERE cm1.user_id = ${userId}
AND cm2.user_id = ${targetUserId}
AND c.organization_id = ${orgId}
AND c.type = 'direct'
LIMIT 1
`);
if (existing.rows.length > 0) {
const existingConvId = (existing.rows[0] as unknown as DirectConvRow).conversation_id;
const meta = await getConversationWithMeta(existingConvId, userId);
return res.json({ success: true, conversation: meta });
}
const [conv] = await db
.insert(conversations)
.values({ organizationId: orgId, type: "direct", createdBy: userId })
.returning();
await db.insert(conversationMembers).values([
{ conversationId: conv.id, userId },
{ conversationId: conv.id, userId: targetUserId },
]);
const meta = await getConversationWithMeta(conv.id, userId);
const metaForTarget = await getConversationWithMeta(conv.id, targetUserId);
eventBus.publishEvent({ type: "conv_created", data: metaForTarget, organizationId: orgId, userId: targetUserId });
return res.status(201).json({ success: true, conversation: meta });
}
if (body.type === "group") {
const { name, memberIds } = body;
const allMemberIds = Array.from(new Set([userId, ...memberIds]));
const orgUsers = await db
.select({ id: users.id })
.from(users)
.where(and(inArray(users.id, allMemberIds), eq(users.organizationId, orgId)));
if (orgUsers.length !== allMemberIds.length) {
return res.status(400).json({ success: false, error: "Некоторые пользователи не найдены в организации" });
}
const [conv] = await db
.insert(conversations)
.values({ organizationId: orgId, type: "group", name, createdBy: userId })
.returning();
await db.insert(conversationMembers).values(
allMemberIds.map(uid => ({ conversationId: conv.id, userId: uid }))
);
const meta = await getConversationWithMeta(conv.id, userId);
for (const uid of allMemberIds) {
if (uid !== userId) {
const metaForUid = await getConversationWithMeta(conv.id, uid);
eventBus.publishEvent({ type: "conv_created", data: metaForUid, organizationId: orgId, userId: uid });
}
}
return res.status(201).json({ success: true, conversation: meta });
}
if (body.type === "external_direct") {
const { connectionId, targetUserId } = body;
const [conn] = await db
.select()
.from(orgConnections)
.where(and(eq(orgConnections.id, connectionId), eq(orgConnections.status, "active")));
if (!conn || (conn.initiatorOrgId !== orgId && conn.partnerOrgId !== orgId)) {
return res.status(403).json({ success: false, error: "Активное подключение не найдено" });
}
const partnerOrgId = conn.initiatorOrgId === orgId ? conn.partnerOrgId! : conn.initiatorOrgId;
const [targetUser] = await db
.select({ id: users.id, organizationId: users.organizationId })
.from(users)
.where(and(eq(users.id, targetUserId), eq(users.organizationId, partnerOrgId)));
if (!targetUser) {
return res.status(404).json({ success: false, error: "Пользователь партнёрской организации не найден" });
}
interface DirectConvRow { conversation_id: number }
const existing = await db.execute(sql`
SELECT cm1.conversation_id
FROM conversation_members cm1
JOIN conversation_members cm2 ON cm1.conversation_id = cm2.conversation_id
JOIN conversations c ON c.id = cm1.conversation_id
WHERE cm1.user_id = ${userId}
AND cm2.user_id = ${targetUserId}
AND c.type = 'direct'
LIMIT 1
`);
if (existing.rows.length > 0) {
const existingConvId = (existing.rows[0] as unknown as DirectConvRow).conversation_id;
const meta = await getConversationWithMeta(existingConvId, userId);
return res.json({ success: true, conversation: meta });
}
const [conv] = await db
.insert(conversations)
.values({ organizationId: orgId, type: "direct", createdBy: userId })
.returning();
await db.insert(conversationMembers).values([
{ conversationId: conv.id, userId },
{ conversationId: conv.id, userId: targetUserId, externalOrgId: partnerOrgId },
]);
const meta = await getConversationWithMeta(conv.id, userId);
const metaForTarget = await getConversationWithMeta(conv.id, targetUserId);
eventBus.publishEvent({ type: "conv_created", data: metaForTarget, organizationId: partnerOrgId, userId: targetUserId });
return res.status(201).json({ success: true, conversation: meta });
}
if (body.type === "external_group") {
const { connectionId, name, memberIds } = body;
const [conn] = await db
.select()
.from(orgConnections)
.where(and(eq(orgConnections.id, connectionId), eq(orgConnections.status, "active")));
if (!conn || (conn.initiatorOrgId !== orgId && conn.partnerOrgId !== orgId)) {
return res.status(403).json({ success: false, error: "Активное подключение не найдено" });
}
const partnerOrgId = conn.initiatorOrgId === orgId ? conn.partnerOrgId! : conn.initiatorOrgId;
const localUserIds: number[] = [];
const externalUserIds: number[] = [];
const allUsers = memberIds.length
? await db.select({ id: users.id, organizationId: users.organizationId }).from(users).where(inArray(users.id, memberIds))
: [];
for (const u of allUsers) {
if (u.organizationId === orgId) localUserIds.push(u.id);
else if (u.organizationId === partnerOrgId) externalUserIds.push(u.id);
}
if (externalUserIds.length === 0) {
return res.status(400).json({ success: false, error: "Добавьте хотя бы одного участника из партнёрской организации" });
}
const allLocalIds = Array.from(new Set([userId, ...localUserIds]));
const [conv] = await db
.insert(conversations)
.values({ organizationId: orgId, type: "group", name, createdBy: userId })
.returning();
const memberValues = [
...allLocalIds.map(uid => ({ conversationId: conv.id, userId: uid })),
...externalUserIds.map(uid => ({ conversationId: conv.id, userId: uid, externalOrgId: partnerOrgId })),
];
await db.insert(conversationMembers).values(memberValues);
const meta = await getConversationWithMeta(conv.id, userId);
const allParticipants = [...allLocalIds, ...externalUserIds];
for (const uid of allParticipants) {
if (uid !== userId) {
const metaForUid = await getConversationWithMeta(conv.id, uid);
const uidOrgId = externalUserIds.includes(uid) ? partnerOrgId : orgId;
eventBus.publishEvent({ type: "conv_created", data: metaForUid, organizationId: uidOrgId, userId: uid });
}
}
return res.status(201).json({ success: true, conversation: meta });
}
if (body.type === "bot_direct") {
const { botId } = body;
const [bot] = await db
.select()
.from(bots)
.where(and(eq(bots.id, botId), eq(bots.organizationId, orgId), eq(bots.isActive, true)));
if (!bot) {
return res.status(404).json({ success: false, error: "Бот не найден" });
}
// Check access policy
const policy = bot.accessPolicy as { mode?: string; roles?: string[]; userIds?: number[] } | null;
const hasAccess = !policy || policy.mode === 'all'
|| (policy.mode === 'roles' && (policy.roles ?? []).includes(req.user!.appRole))
|| (policy.mode === 'users' && (policy.userIds ?? []).includes(userId));
if (!hasAccess) {
return res.status(403).json({ success: false, error: `Нет доступа к боту ${bot.name}` });
}
// Find existing bot_direct conversation for this user+bot
const existingRows = await db.execute(sql`
SELECT c.id
FROM conversations c
JOIN conversation_members cm ON cm.conversation_id = c.id
WHERE c.type = 'bot_direct'
AND c.bot_id = ${botId}
AND c.organization_id = ${orgId}
AND cm.user_id = ${userId}
LIMIT 1
`);
if (existingRows.rows.length > 0) {
interface BotConvRow { id: number }
const existingConvId = (existingRows.rows[0] as unknown as BotConvRow).id;
const meta = await getConversationWithMeta(existingConvId, userId);
return res.json({ success: true, conversation: meta });
}
const [conv] = await db
.insert(conversations)
.values({ organizationId: orgId, type: "bot_direct", botId, createdBy: userId })
.returning();
await db.insert(conversationMembers).values([{ conversationId: conv.id, userId }]);
const meta = await getConversationWithMeta(conv.id, userId);
return res.status(201).json({ success: true, conversation: meta });
}
return res.status(400).json({ success: false, error: "Некорректный тип" });
} catch (err) {
console.error("create conversation error:", err);
res.status(500).json({ success: false, error: "Ошибка при создании диалога" });
}
}
);
// GET /api/messenger/conversations/:id
router.get("/api/messenger/conversations/:id",
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 meta = await getConversationWithMeta(convId, userId);
if (!meta) return res.status(404).json({ success: false, error: "Диалог не найден" });
res.json({ success: true, conversation: meta });
} catch (err) {
console.error("get conversation error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// PATCH /api/messenger/conversations/:id — rename group chat
router.patch("/api/messenger/conversations/:id",
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 { name } = req.body;
if (typeof name !== "string" || !name.trim()) {
return res.status(400).json({ success: false, error: "Имя не может быть пустым" });
}
await db
.update(conversations)
.set({ name: name.trim(), updatedAt: new Date() })
.where(eq(conversations.id, convId));
const meta = await getConversationWithMeta(convId, userId);
res.json({ success: true, conversation: meta });
} catch (err) {
console.error("patch conversation error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// POST /api/messenger/conversations/:id/members
router.post("/api/messenger/conversations/:id/members",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const orgId = req.organizationId!;
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 [conv] = await db
.select({ type: conversations.type, organizationId: conversations.organizationId, createdBy: conversations.createdBy, isDefault: conversations.isDefault })
.from(conversations)
.where(eq(conversations.id, convId));
if (conv?.type !== "group") {
return res.status(400).json({ success: false, error: "Нельзя добавить участника в личный диалог" });
}
const isGroupCreator = conv.createdBy === userId;
const [reqUser] = await db.select({ appRole: users.appRole }).from(users).where(eq(users.id, userId));
const isOrgAdmin = reqUser?.appRole === "admin";
if (conv.isDefault) {
if (!isOrgAdmin) {
return res.status(403).json({ success: false, error: "Добавлять участников в эту группу может только администратор организации" });
}
} else if (!isGroupCreator && !isOrgAdmin) {
return res.status(403).json({ success: false, error: "Добавлять участников может только создатель группы или администратор организации" });
}
const { targetUserId } = req.body;
if (!targetUserId) return res.status(400).json({ success: false, error: "Укажите пользователя" });
const convHostOrgId = conv.organizationId;
const [targetUser] = await db
.select({ id: users.id, organizationId: users.organizationId })
.from(users)
.where(eq(users.id, targetUserId));
if (!targetUser) return res.status(404).json({ success: false, error: "Пользователь не найден" });
const isExternal = targetUser.organizationId !== convHostOrgId;
let externalOrgId: number | null = null;
if (isExternal) {
const [activeConn] = await db
.select({ id: orgConnections.id })
.from(orgConnections)
.where(and(
eq(orgConnections.status, "active"),
or(
and(eq(orgConnections.initiatorOrgId, convHostOrgId), eq(orgConnections.partnerOrgId!, targetUser.organizationId)),
and(eq(orgConnections.initiatorOrgId, targetUser.organizationId), eq(orgConnections.partnerOrgId!, convHostOrgId)),
)
));
if (!activeConn) {
return res.status(403).json({ success: false, error: "Нет активного подключения с организацией этого пользователя" });
}
externalOrgId = targetUser.organizationId;
} else {
if (targetUser.organizationId !== convHostOrgId) {
return res.status(404).json({ success: false, error: "Пользователь не найден в организации" });
}
}
await db
.insert(conversationMembers)
.values({ conversationId: convId, userId: targetUserId, externalOrgId })
.onConflictDoNothing();
const meta = await getConversationWithMeta(convId, userId);
const metaForNewMember = await getConversationWithMeta(convId, targetUserId);
eventBus.publishEvent({
type: "conv_created",
data: metaForNewMember,
organizationId: externalOrgId ?? convHostOrgId,
userId: targetUserId,
});
res.json({ success: true, conversation: meta });
} catch (err) {
console.error("add member error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// PATCH /api/messenger/conversations/:id/mute — toggle mute for current user
router.patch("/api/messenger/conversations/:id/mute",
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 { muted } = req.body;
if (typeof muted !== "boolean") {
return res.status(400).json({ success: false, error: "Параметр muted должен быть boolean" });
}
await db
.update(conversationMembers)
.set({ mutedAt: muted ? new Date() : null })
.where(and(
eq(conversationMembers.conversationId, convId),
eq(conversationMembers.userId, userId),
));
const meta = await getConversationWithMeta(convId, userId);
res.json({ success: true, conversation: meta });
} catch (err) {
console.error("mute conversation error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// DELETE /api/messenger/conversations/:id/members/:memberId
router.delete("/api/messenger/conversations/:id/members/:memberId",
authenticateToken,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
const userId = req.user!.id;
const convId = parseInt(req.params.id);
const targetId = parseInt(req.params.memberId);
if (isNaN(convId) || isNaN(targetId)) return res.status(400).json({ success: false, error: "Некорректный ID" });
if (!await isMember(convId, userId)) {
return res.status(403).json({ success: false, error: "Нет доступа" });
}
const [conv] = await db
.select({ createdBy: conversations.createdBy, type: conversations.type })
.from(conversations)
.where(eq(conversations.id, convId));
if (!conv) return res.status(404).json({ success: false, error: "Диалог не найден" });
if (conv.type !== "group") return res.status(400).json({ success: false, error: "Нельзя удалить из личного диалога" });
const [reqUser] = await db.select({ appRole: users.appRole }).from(users).where(eq(users.id, userId));
const isOrgAdmin = reqUser?.appRole === "admin";
if (targetId !== userId && conv.createdBy !== userId && !isOrgAdmin) {
return res.status(403).json({ success: false, error: "Недостаточно прав" });
}
await db
.delete(conversationMembers)
.where(and(
eq(conversationMembers.conversationId, convId),
eq(conversationMembers.userId, targetId),
));
res.json({ success: true });
} catch (err) {
console.error("remove member error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
// DELETE /api/messenger/conversations/:id — delete group conversation
router.delete("/api/messenger/conversations/:id",
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 [conv] = await db
.select({ createdBy: conversations.createdBy, type: conversations.type, isDefault: conversations.isDefault })
.from(conversations)
.where(eq(conversations.id, convId));
if (!conv) return res.status(404).json({ success: false, error: "Диалог не найден" });
if (conv.type !== "group") return res.status(400).json({ success: false, error: "Нельзя удалить личный диалог" });
if (conv.isDefault) return res.status(403).json({ success: false, error: "Нельзя удалить системную группу" });
const [reqUser] = await db.select({ appRole: users.appRole }).from(users).where(eq(users.id, userId));
const isOrgAdmin = reqUser?.appRole === "admin";
if (conv.createdBy !== userId && !isOrgAdmin) {
return res.status(403).json({ success: false, error: "Удалить группу может только создатель или администратор организации" });
}
await db.delete(conversations).where(eq(conversations.id, convId));
res.json({ success: true });
} catch (err) {
console.error("delete conversation error:", err);
res.status(500).json({ success: false, error: "Ошибка" });
}
}
);
}