import { Router } from "express"; import { z } from "zod"; import { db } from "../db"; import { polls, pollVotes, taskMessages, tasks, conversationMessages, conversationMembers, users } from "@shared/schema"; import { eq, and, inArray } from "drizzle-orm"; import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { getPollDataById, getTaskMsgContext, getConvMsgContext, emitTaskPoll, emitConvPoll, type PollData, } from "./reaction-helpers"; const router = Router(); // GET /api/tasks/:taskId/polls — batch polls for task messages (org-scoped) router.get('/api/tasks/:taskId/polls', 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); if (!msgIds.length) return res.json({ success: true, polls: {} }); const pollRows = await db.select().from(polls).where( and(inArray(polls.taskMessageId, msgIds), eq(polls.organizationId, req.organizationId!)) ); if (!pollRows.length) return res.json({ success: true, polls: {} }); const pollIds = pollRows.map(p => p.id); const voteRows = await db .select({ pollId: pollVotes.pollId, userId: pollVotes.userId, optionIndexes: pollVotes.optionIndexes, firstName: users.firstName, middleName: users.middleName, lastName: users.lastName, }) .from(pollVotes) .innerJoin(users, eq(pollVotes.userId, users.id)) .where(inArray(pollVotes.pollId, pollIds)); const result: Record = {}; for (const p of pollRows) { const msgId = p.taskMessageId; if (msgId == null) continue; const pvRows = voteRows.filter(v => v.pollId === p.id); const optCount = (p.options as string[]).length; const voteCounts = new Array(optCount).fill(0); const voters: { id: number; firstName: string; middleName: string; lastName: string }[][] = Array.from({ length: optCount }, () => []); let myVote: number[] | null = null; for (const v of pvRows) { if (v.userId === req.user!.id) myVote = v.optionIndexes; for (const idx of v.optionIndexes) { if (idx >= 0 && idx < optCount) { voteCounts[idx]++; voters[idx].push({ id: v.userId, firstName: v.firstName, middleName: v.middleName ?? '', lastName: v.lastName }); } } } result[msgId] = { id: p.id, question: p.question, options: p.options as string[], isMultiple: p.isMultiple ?? false, isClosed: p.isClosed ?? false, createdBy: p.createdBy, totalVotes: new Set(pvRows.map(v => v.userId)).size, myVote, optionVoteCounts: voteCounts, optionVoters: voters, }; } return res.json({ success: true, polls: result }); } catch (err: any) { console.error('[POLLS] Ошибка загрузки опросов задачи:', err); return res.status(500).json({ success: false, error: 'Ошибка загрузки опросов' }); } }); // GET /api/messenger/conversations/:id/polls — batch polls for conv messages (members only) router.get('/api/messenger/conversations/:id/polls', 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); if (!msgIds.length) return res.json({ success: true, polls: {} }); const pollRows = await db.select().from(polls).where( and(inArray(polls.convMessageId, msgIds), eq(polls.organizationId, req.organizationId!)) ); if (!pollRows.length) return res.json({ success: true, polls: {} }); const pollIds = pollRows.map(p => p.id); const voteRows = await db .select({ pollId: pollVotes.pollId, userId: pollVotes.userId, optionIndexes: pollVotes.optionIndexes, firstName: users.firstName, middleName: users.middleName, lastName: users.lastName, }) .from(pollVotes) .innerJoin(users, eq(pollVotes.userId, users.id)) .where(inArray(pollVotes.pollId, pollIds)); const result: Record = {}; for (const p of pollRows) { const msgId = p.convMessageId; if (msgId == null) continue; const pvRows = voteRows.filter(v => v.pollId === p.id); const optCount = (p.options as string[]).length; const voteCounts = new Array(optCount).fill(0); const voters: { id: number; firstName: string; middleName: string; lastName: string }[][] = Array.from({ length: optCount }, () => []); let myVote: number[] | null = null; for (const v of pvRows) { if (v.userId === req.user!.id) myVote = v.optionIndexes; for (const idx of v.optionIndexes) { if (idx >= 0 && idx < optCount) { voteCounts[idx]++; voters[idx].push({ id: v.userId, firstName: v.firstName, middleName: v.middleName ?? '', lastName: v.lastName }); } } } result[msgId] = { id: p.id, question: p.question, options: p.options as string[], isMultiple: p.isMultiple ?? false, isClosed: p.isClosed ?? false, createdBy: p.createdBy, totalVotes: new Set(pvRows.map(v => v.userId)).size, myVote, optionVoteCounts: voteCounts, optionVoters: voters, }; } return res.json({ success: true, polls: result }); } catch (err: any) { console.error('[POLLS] Ошибка загрузки опросов диалога:', err); return res.status(500).json({ success: false, error: 'Ошибка загрузки опросов' }); } }); // POST /api/polls — create poll linked to a task or conv message router.post('/api/polls', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const schema = z.object({ taskMessageId: z.number().int().positive().optional(), convMessageId: z.number().int().positive().optional(), question: z.string().min(1).max(500), options: z.array(z.string().min(1).max(200)).min(2).max(10), isMultiple: z.boolean().optional().default(false), }); const parseResult = schema.safeParse(req.body); if (!parseResult.success) return res.status(400).json({ success: false, error: 'Неверный запрос' }); const body = parseResult.data; const hasTask = !!body.taskMessageId; const hasConv = !!body.convMessageId; if (!hasTask && !hasConv) { return res.status(400).json({ success: false, error: 'Необходимо указать taskMessageId или convMessageId' }); } if (hasTask && hasConv) { return res.status(400).json({ success: false, error: 'Укажите только одно: taskMessageId или convMessageId' }); } try { if (body.taskMessageId) { const ctx = await getTaskMsgContext(body.taskMessageId, req.organizationId!); if (!ctx) return res.status(404).json({ success: false, error: 'Сообщение не найдено' }); } else if (body.convMessageId) { const ctx = await getConvMsgContext(body.convMessageId, req.organizationId!, req.user!.id); if (!ctx) return res.status(404).json({ success: false, error: 'Сообщение не найдено или нет доступа' }); } const [poll] = await db.insert(polls).values({ taskMessageId: body.taskMessageId ?? null, convMessageId: body.convMessageId ?? null, organizationId: req.organizationId!, question: body.question, options: body.options, isMultiple: body.isMultiple, isClosed: false, createdBy: req.user!.id, }).returning(); const pollData = await getPollDataById(poll.id, req.user!.id); if (body.taskMessageId) { emitTaskPoll(req.organizationId!, { taskMessageId: body.taskMessageId, poll: pollData }); } else if (body.convMessageId) { const [msg] = await db .select({ conversationId: conversationMessages.conversationId }) .from(conversationMessages) .where(eq(conversationMessages.id, body.convMessageId!)); if (msg?.conversationId) { await emitConvPoll(msg.conversationId, req.organizationId!, { convMessageId: body.convMessageId, conversationId: msg.conversationId, poll: pollData }); } } return res.json({ success: true, poll: pollData }); } catch (err: any) { console.error('[POLLS] Ошибка создания опроса:', err); return res.status(500).json({ success: false, error: 'Ошибка создания опроса' }); } }); // POST /api/polls/:id/vote — vote on poll (or change vote) router.post('/api/polls/:id/vote', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const pollId = parseInt(req.params.id); if (isNaN(pollId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); const parseResult = z.object({ optionIndexes: z.array(z.number().int().min(0)).min(1) }).safeParse(req.body); if (!parseResult.success) return res.status(400).json({ success: false, error: 'Неверный запрос' }); const { optionIndexes } = parseResult.data; try { const [poll] = await db.select().from(polls).where( and(eq(polls.id, pollId), eq(polls.organizationId, req.organizationId!)) ); if (!poll) return res.status(404).json({ success: false, error: 'Опрос не найден' }); if (poll.isClosed) return res.status(400).json({ success: false, error: 'Опрос закрыт' }); if (!poll.isMultiple && optionIndexes.length > 1) { return res.status(400).json({ success: false, error: 'Этот опрос допускает только один вариант' }); } if (poll.taskMessageId) { const ctx = await getTaskMsgContext(poll.taskMessageId, req.organizationId!); if (!ctx) return res.status(403).json({ success: false, error: 'Нет доступа' }); } else if (poll.convMessageId) { const ctx = await getConvMsgContext(poll.convMessageId, req.organizationId!, req.user!.id); if (!ctx) return res.status(403).json({ success: false, error: 'Нет доступа' }); } const validIndexes = optionIndexes.filter(i => i >= 0 && i < (poll.options as string[]).length); const existing = await db.select().from(pollVotes).where( and(eq(pollVotes.pollId, pollId), eq(pollVotes.userId, req.user!.id)) ); if (existing.length > 0) { await db.update(pollVotes) .set({ optionIndexes: validIndexes, updatedAt: new Date() }) .where(eq(pollVotes.id, existing[0].id)); } else { await db.insert(pollVotes).values({ pollId, userId: req.user!.id, optionIndexes: validIndexes }); } const pollData = await getPollDataById(pollId, req.user!.id); if (poll.taskMessageId) { emitTaskPoll(req.organizationId!, { taskMessageId: poll.taskMessageId, poll: pollData }); } else if (poll.convMessageId) { const convCtx = await getConvMsgContext(poll.convMessageId, req.organizationId!, req.user!.id); if (convCtx) await emitConvPoll(convCtx.conversationId, req.organizationId!, { convMessageId: poll.convMessageId, conversationId: convCtx.conversationId, poll: pollData }); } return res.json({ success: true, poll: pollData }); } catch (err: any) { console.error('[POLLS] Ошибка голосования:', err); return res.status(500).json({ success: false, error: 'Ошибка голосования' }); } }); // DELETE /api/polls/:id/vote — remove own vote router.delete('/api/polls/:id/vote', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const pollId = parseInt(req.params.id); if (isNaN(pollId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); try { const [poll] = await db.select().from(polls).where( and(eq(polls.id, pollId), eq(polls.organizationId, req.organizationId!)) ); if (!poll) return res.status(404).json({ success: false, error: 'Опрос не найден' }); if (poll.isClosed) return res.status(400).json({ success: false, error: 'Опрос закрыт' }); if (poll.taskMessageId) { const ctx = await getTaskMsgContext(poll.taskMessageId, req.organizationId!); if (!ctx) return res.status(403).json({ success: false, error: 'Нет доступа' }); } else if (poll.convMessageId) { const ctx = await getConvMsgContext(poll.convMessageId, req.organizationId!, req.user!.id); if (!ctx) return res.status(403).json({ success: false, error: 'Нет доступа' }); } await db.delete(pollVotes).where( and(eq(pollVotes.pollId, pollId), eq(pollVotes.userId, req.user!.id)) ); const pollData = await getPollDataById(pollId, req.user!.id); if (poll.taskMessageId) { emitTaskPoll(req.organizationId!, { taskMessageId: poll.taskMessageId, poll: pollData }); } else if (poll.convMessageId) { const convCtx = await getConvMsgContext(poll.convMessageId, req.organizationId!, req.user!.id); if (convCtx) await emitConvPoll(convCtx.conversationId, req.organizationId!, { convMessageId: poll.convMessageId, conversationId: convCtx.conversationId, poll: pollData }); } return res.json({ success: true, poll: pollData }); } catch (err: any) { console.error('[POLLS] Ошибка удаления голоса:', err); return res.status(500).json({ success: false, error: 'Ошибка удаления голоса' }); } }); // POST /api/polls/:id/close — close poll (only creator or admin) router.post('/api/polls/:id/close', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => { const pollId = parseInt(req.params.id); if (isNaN(pollId)) return res.status(400).json({ success: false, error: 'Неверный ID' }); try { const [poll] = await db.select().from(polls).where( and(eq(polls.id, pollId), eq(polls.organizationId, req.organizationId!)) ); if (!poll) return res.status(404).json({ success: false, error: 'Опрос не найден' }); if (poll.createdBy !== req.user!.id && req.user!.appRole !== 'admin') { return res.status(403).json({ success: false, error: 'Нет доступа' }); } await db.update(polls).set({ isClosed: true }).where(eq(polls.id, pollId)); const pollData = await getPollDataById(pollId, req.user!.id); if (poll.taskMessageId) { emitTaskPoll(req.organizationId!, { taskMessageId: poll.taskMessageId, poll: pollData }); } else if (poll.convMessageId) { const convCtx = await getConvMsgContext(poll.convMessageId, req.organizationId!, req.user!.id); if (convCtx) await emitConvPoll(convCtx.conversationId, req.organizationId!, { convMessageId: poll.convMessageId, conversationId: convCtx.conversationId, poll: pollData }); } return res.json({ success: true, poll: pollData }); } catch (err: any) { console.error('[POLLS] Ошибка закрытия опроса:', err); return res.status(500).json({ success: false, error: 'Ошибка закрытия опроса' }); } }); export default router;