changemaker.lite/api/src/modules/polls/polls-public.routes.ts
bunker-admin 902adce646 Add Straw Polls feature: quick opinion polling with public landers, MkDocs widgets, and social integration
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
2026-03-31 10:16:56 -06:00

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 };