Files
iistwin/server/routes/polls.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

321 lines
16 KiB
TypeScript

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<number, PollData> = {};
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<number>(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<number, PollData> = {};
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<number>(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;