Шаг 0.7 плана production-готовности: - глобальный error handler: нейтральный текст + код инцидента, детали в лог - route-уровень: зачистка err.message/String(err) в 5xx по 12 файлам, оригинальные ошибки логируются с контекстом
250 lines
12 KiB
TypeScript
250 lines
12 KiB
TypeScript
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;
|