Files
iistwin/server/routes/reactions.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

244 lines
11 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) {
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;