Full-stack implementation across 7 sprints: - Backend: 5 Prisma models (StrawPoll, Option, Vote, Comment, Challenge), 4 enums, POLLS_ADMIN role, admin CRUD routes, public voting/SSE/widget endpoints, BullMQ auto-close queue, rate limiting - Admin: StrawPollsPage with inline drawers (campaigns pattern), PollResults bar chart, sidebar under Advocacy - Public: dedicated poll lander with real-time SSE updates, browse page, anonymous voting with token dedup - MkDocs: straw-poll-widget.js hydration (inline vote + card link modes), GrapesJS block types - Social: feed activity (poll_voted), friend badge integration, challenge notifications, notification preferences - Feature flag: enablePolls toggle in Settings, FeatureGate, Zod schema Bunker Admin
124 lines
4.6 KiB
TypeScript
124 lines
4.6 KiB
TypeScript
import { Router, Request, Response, NextFunction } from 'express';
|
|
import { strawPollsService } from './polls.service';
|
|
import {
|
|
listStrawPollsSchema,
|
|
submitStrawPollVoteSchema,
|
|
submitStrawPollCommentSchema,
|
|
challengeVoteSchema,
|
|
} from './polls.schemas';
|
|
import { validate } from '../../middleware/validate';
|
|
import { authenticate, optionalAuth } from '../../middleware/auth.middleware';
|
|
import { strawPollVoteRateLimit, strawPollCommentRateLimit } from './polls.rate-limits';
|
|
import { pollSseService } from './polls-sse.service';
|
|
|
|
const publicRouter = Router();
|
|
|
|
// List active public polls
|
|
publicRouter.get('/public', validate(listStrawPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const result = await strawPollsService.findAllPublic(req.query as any);
|
|
res.json(result);
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// Get poll by slug (public)
|
|
publicRouter.get('/public/:slug', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
const voterToken = req.query.voterToken as string | undefined;
|
|
const clientIp = req.ip || req.socket.remoteAddress || '';
|
|
const poll = await strawPollsService.findBySlugPublic(slug, req.user?.id, voterToken, clientIp);
|
|
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
|
|
|
// For AFTER_VOTE visibility, strip results if not voted
|
|
if ('resultVisibility' in poll && poll.resultVisibility === 'AFTER_VOTE' && !poll.hasVoted) {
|
|
const stripped = {
|
|
...poll,
|
|
options: (poll as any).options?.map((o: any) => ({ ...o, voteCount: undefined })),
|
|
totalVotes: undefined,
|
|
showResults: false,
|
|
};
|
|
return res.json(stripped);
|
|
}
|
|
|
|
res.json(poll);
|
|
} catch (err) { next(err); }
|
|
});
|
|
|
|
// Submit vote
|
|
publicRouter.post('/public/:slug/vote', optionalAuth, strawPollVoteRateLimit, validate(submitStrawPollVoteSchema), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
const clientIp = req.ip || req.socket.remoteAddress || '';
|
|
const result = await strawPollsService.submitVote(slug, req.body, req.user?.id, clientIp);
|
|
res.json(result);
|
|
} catch (err) {
|
|
if (err instanceof Error && (err.message.includes('not found') || err.message.includes('not active') || err.message.includes('Invalid option'))) {
|
|
return res.status(400).json({ error: err.message });
|
|
}
|
|
if (err instanceof Error && err.message.includes('required')) {
|
|
return res.status(401).json({ error: err.message });
|
|
}
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// Submit comment
|
|
publicRouter.post('/public/:slug/comment', optionalAuth, strawPollCommentRateLimit, validate(submitStrawPollCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
const comment = await strawPollsService.addComment(slug, req.body, req.user?.id);
|
|
res.status(201).json(comment);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message.includes('disabled')) {
|
|
return res.status(400).json({ error: err.message });
|
|
}
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
// SSE stream for live results
|
|
publicRouter.get('/public/:slug/live', (req: Request, res: Response) => {
|
|
const slug = req.params.slug as string;
|
|
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream',
|
|
'Cache-Control': 'no-cache',
|
|
'Connection': 'keep-alive',
|
|
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
|
});
|
|
res.write(': connected\n\n');
|
|
|
|
const connectionId = pollSseService.addClient(slug, res);
|
|
if (!connectionId) {
|
|
res.write('event: error\ndata: {"message":"Too many connections"}\n\n');
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
req.on('close', () => {
|
|
pollSseService.removeClient(connectionId);
|
|
});
|
|
});
|
|
|
|
// Challenge a friend (requires auth)
|
|
publicRouter.post('/public/:slug/challenge', authenticate, validate(challengeVoteSchema), async (req: Request, res: Response, next: NextFunction) => {
|
|
try {
|
|
const slug = req.params.slug as string;
|
|
// Look up poll ID from slug
|
|
const { prisma } = await import('../../config/database');
|
|
const poll = await prisma.strawPoll.findUnique({ where: { slug }, select: { id: true } });
|
|
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
|
|
|
const challenge = await strawPollsService.challengeFriend(poll.id, req.user!.id, req.body.challengedUserId);
|
|
res.status(201).json(challenge);
|
|
} catch (err) {
|
|
if (err instanceof Error && err.message.includes('not found')) {
|
|
return res.status(404).json({ error: err.message });
|
|
}
|
|
next(err);
|
|
}
|
|
});
|
|
|
|
export { publicRouter as strawPollPublicRouter };
|