- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
244 lines
11 KiB
TypeScript
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;
|