Files
iistwin/server/routes/reactions.routes.ts
Ильяс Султанов 26419f395f security(errors): 5xx без утечки err.message клиенту
Шаг 0.7 плана production-готовности:
- глобальный error handler: нейтральный текст + код инцидента, детали в лог
- route-уровень: зачистка err.message/String(err) в 5xx по 12 файлам,
  оригинальные ошибки логируются с контекстом
2026-09-07 20:42:39 +03:00

250 lines
12 KiB
TypeScript
Raw Permalink 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 { 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) {
console.error('[REACTIONS] Ошибка загрузки реакций задачи:', err);
return res.status(500).json({ success: false, error: 'Ошибка загрузки реакций' });
}
});
// 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) {
console.error('[REACTIONS] Ошибка загрузки реакций диалога:', err);
return res.status(500).json({ success: false, error: 'Ошибка загрузки реакций' });
}
});
// 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) {
console.error('[REACTIONS] Ошибка обновления реакции на сообщение задачи:', err);
return res.status(500).json({ success: false, error: 'Ошибка обновления реакции' });
}
});
// 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) {
console.error('[REACTIONS] Ошибка удаления реакции с сообщения задачи:', err);
return res.status(500).json({ success: false, error: 'Ошибка удаления реакции' });
}
});
// 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) {
console.error('[REACTIONS] Ошибка обновления реакции на сообщение диалога:', err);
return res.status(500).json({ success: false, error: 'Ошибка обновления реакции' });
}
});
// 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) {
console.error('[REACTIONS] Ошибка удаления реакции с сообщения диалога:', err);
return res.status(500).json({ success: false, error: 'Ошибка удаления реакции' });
}
});
export default router;