import { Router } from "express"; import { z } from "zod"; import { db } from "../db"; import { messageReactions, taskMessages, tasks, conversationMessages, conversationMembers, users, } from "@shared/schema"; import { eq, and, sql } from "drizzle-orm"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { notificationService } from "../services/notification.service"; import { getTaskMsgContext, getConvMsgContext, getReactionsForTaskMsgs, getReactionsForConvMsgs, getReactionGroupsForMsg, emitTaskReaction, emitConvReaction, } from "./reaction-helpers"; const router = Router(); // GET /api/tasks/:taskId/reactions — batch reactions for all task messages in org router.get('/api/tasks/:taskId/reactions', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const taskId = parseInt(req.params.taskId); if (isNaN(taskId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); try { const msgIds = (await db .select({ id: taskMessages.id }) .from(taskMessages) .innerJoin(tasks, eq(taskMessages.taskId, tasks.id)) .where(and(eq(taskMessages.taskId, taskId), eq(tasks.organizationId, req.organizationId!))) ).map(r => r.id); const reactions = await getReactionsForTaskMsgs(msgIds, req.user!.id); return res.json({ success: true, reactions }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); // GET /api/messenger/conversations/:id/reactions — batch reactions for conv messages (members only) router.get('/api/messenger/conversations/:id/reactions', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const convId = parseInt(req.params.id); if (isNaN(convId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); try { const [member] = await db .select({ id: conversationMembers.id }) .from(conversationMembers) .where(and( eq(conversationMembers.conversationId, convId), eq(conversationMembers.userId, req.user!.id), )); if (!member) return res.status(403).json({ success: false, error: 'Нет доступа' }); const msgIds = (await db .select({ id: conversationMessages.id }) .from(conversationMessages) .where(eq(conversationMessages.conversationId, convId)) ).map(r => r.id); const reactions = await getReactionsForConvMsgs(msgIds, req.user!.id); return res.json({ success: true, reactions }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); // POST /api/messages/:id/reactions — toggle reaction on task message router.post('/api/messages/:id/reactions', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const msgId = parseInt(req.params.id); if (isNaN(msgId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); const parseResult = z.object({ emoji: z.string().min(1).max(10) }).safeParse(req.body); if (!parseResult.success) return res.status(400).json({ success: false, error: 'Неверный эмодзи' }); const { emoji } = parseResult.data; try { const ctx = await getTaskMsgContext(msgId, req.organizationId!); if (!ctx) return res.status(404).json({ success: false, error: 'Сообщение не найдено' }); const existing = await db.select().from(messageReactions).where( and( eq(messageReactions.taskMessageId, msgId), eq(messageReactions.userId, req.user!.id), eq(messageReactions.emoji, emoji), ) ); const isAdding = existing.length === 0; if (!isAdding) { await db.delete(messageReactions).where(eq(messageReactions.id, existing[0].id)); } else { await db.insert(messageReactions).values({ taskMessageId: msgId, userId: req.user!.id, organizationId: req.organizationId!, emoji, }); } const reactions = await getReactionGroupsForMsg(msgId, null, req.user!.id); emitTaskReaction(req.organizationId!, { taskMessageId: msgId, taskId: ctx.taskId, reactions }); if (isAdding) { const [msgRow] = await db .select({ authorId: taskMessages.authorId }) .from(taskMessages) .where(eq(taskMessages.id, msgId)); if (msgRow?.authorId && msgRow.authorId !== req.user!.id) { const reactorName = `${req.user!.firstName || ''} ${req.user!.middleName || ''} ${req.user!.lastName || ''}`.trim() || req.user!.email; notificationService.notifyMessageReaction({ reactorId: req.user!.id, reactorName, messageAuthorId: msgRow.authorId, emoji, organizationId: req.organizationId!, taskId: ctx.taskId, formId: ctx.formId, messageId: msgId, }).catch(err => console.error('[reaction] Failed to notify task message author:', err)); db.update(tasks) .set({ updatedAt: new Date() }) .where(eq(tasks.id, ctx.taskId)) .catch(err => console.error('[reaction] Failed to update task updatedAt:', err)); } } return res.json({ success: true, reactions }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); // DELETE /api/messages/:id/reactions — remove specific reaction from task message router.delete('/api/messages/:id/reactions', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const msgId = parseInt(req.params.id); if (isNaN(msgId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); const parseResult = z.object({ emoji: z.string().min(1).max(10) }).safeParse(req.body); if (!parseResult.success) return res.status(400).json({ success: false, error: 'Неверный эмодзи' }); const { emoji } = parseResult.data; try { const ctx = await getTaskMsgContext(msgId, req.organizationId!); if (!ctx) return res.status(404).json({ success: false, error: 'Сообщение не найдено' }); await db.delete(messageReactions).where( and( eq(messageReactions.taskMessageId, msgId), eq(messageReactions.userId, req.user!.id), eq(messageReactions.emoji, emoji), ) ); const reactions = await getReactionGroupsForMsg(msgId, null, req.user!.id); emitTaskReaction(req.organizationId!, { taskMessageId: msgId, taskId: ctx.taskId, reactions }); return res.json({ success: true, reactions }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); // POST /api/conv-messages/:id/reactions — toggle reaction on conv message (members only) router.post('/api/conv-messages/:id/reactions', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const msgId = parseInt(req.params.id); if (isNaN(msgId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); const parseResult = z.object({ emoji: z.string().min(1).max(10) }).safeParse(req.body); if (!parseResult.success) return res.status(400).json({ success: false, error: 'Неверный эмодзи' }); const { emoji } = parseResult.data; try { const ctx = await getConvMsgContext(msgId, req.organizationId!, req.user!.id); if (!ctx) return res.status(404).json({ success: false, error: 'Сообщение не найдено или нет доступа' }); const existing = await db.select().from(messageReactions).where( and( eq(messageReactions.convMessageId, msgId), eq(messageReactions.userId, req.user!.id), eq(messageReactions.emoji, emoji), ) ); const isAdding = existing.length === 0; if (!isAdding) { await db.delete(messageReactions).where(eq(messageReactions.id, existing[0].id)); } else { await db.insert(messageReactions).values({ convMessageId: msgId, userId: req.user!.id, organizationId: req.organizationId!, emoji, }); } const reactions = await getReactionGroupsForMsg(null, msgId, req.user!.id); await emitConvReaction(ctx.conversationId, req.organizationId!, { convMessageId: msgId, conversationId: ctx.conversationId, reactions }); if (isAdding) { const [msgRow] = await db .select({ authorId: conversationMessages.authorId }) .from(conversationMessages) .where(eq(conversationMessages.id, msgId)); if (msgRow?.authorId && msgRow.authorId !== req.user!.id) { const [authorUser] = await db .select({ organizationId: users.organizationId }) .from(users) .where(eq(users.id, msgRow.authorId)); const authorOrgId = authorUser?.organizationId ?? req.organizationId!; const reactorName = `${req.user!.firstName || ''} ${req.user!.middleName || ''} ${req.user!.lastName || ''}`.trim() || req.user!.email; notificationService.notifyMessageReaction({ reactorId: req.user!.id, reactorName, messageAuthorId: msgRow.authorId, emoji, organizationId: authorOrgId, }).catch(err => console.error('[reaction] Failed to notify conv message author:', err)); db.update(conversationMembers) .set({ reactionUnreadCount: sql`${conversationMembers.reactionUnreadCount} + 1` }) .where(and( eq(conversationMembers.conversationId, ctx.conversationId), eq(conversationMembers.userId, msgRow.authorId), )) .catch(err => console.error('[reaction] Failed to increment conv reactionUnreadCount:', err)); } } return res.json({ success: true, reactions }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); // DELETE /api/conv-messages/:id/reactions — remove specific reaction from conv message router.delete('/api/conv-messages/:id/reactions', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const msgId = parseInt(req.params.id); if (isNaN(msgId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); const parseResult = z.object({ emoji: z.string().min(1).max(10) }).safeParse(req.body); if (!parseResult.success) return res.status(400).json({ success: false, error: 'Неверный эмодзи' }); const { emoji } = parseResult.data; try { const ctx = await getConvMsgContext(msgId, req.organizationId!, req.user!.id); if (!ctx) return res.status(404).json({ success: false, error: 'Сообщение не найдено или нет доступа' }); await db.delete(messageReactions).where( and( eq(messageReactions.convMessageId, msgId), eq(messageReactions.userId, req.user!.id), eq(messageReactions.emoji, emoji), ) ); const reactions = await getReactionGroupsForMsg(null, msgId, req.user!.id); await emitConvReaction(ctx.conversationId, req.organizationId!, { convMessageId: msgId, conversationId: ctx.conversationId, reactions }); return res.json({ success: true, reactions }); } catch (err: any) { return res.status(500).json({ success: false, error: err.message }); } }); export default router;