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