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
This commit is contained in:
2026-03-31 10:16:56 -06:00
parent 68434c51a6
commit 902adce646
30 changed files with 2766 additions and 6 deletions

View File

@@ -0,0 +1,168 @@
-- CreateEnum
CREATE TYPE "StrawPollType" AS ENUM ('SINGLE_CHOICE', 'YES_NO_ABSTAIN');
-- CreateEnum
CREATE TYPE "StrawPollStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "StrawPollIdentityMode" AS ENUM ('ANONYMOUS', 'TOKEN_GATED', 'AUTHENTICATED', 'MIXED');
-- CreateEnum
CREATE TYPE "StrawPollResultVisibility" AS ENUM ('LIVE', 'AFTER_VOTE', 'AFTER_CLOSE', 'CREATOR_ONLY', 'PUBLIC_ALWAYS');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'poll_closed';
ALTER TYPE "NotificationType" ADD VALUE 'poll_results_available';
ALTER TYPE "NotificationType" ADD VALUE 'poll_challenge';
-- AlterEnum
ALTER TYPE "UserRole" ADD VALUE 'POLLS_ADMIN';
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_polls" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "straw_polls" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" VARCHAR(200) NOT NULL,
"description" TEXT,
"type" "StrawPollType" NOT NULL,
"status" "StrawPollStatus" NOT NULL DEFAULT 'DRAFT',
"identity_mode" "StrawPollIdentityMode" NOT NULL DEFAULT 'ANONYMOUS',
"result_visibility" "StrawPollResultVisibility" NOT NULL DEFAULT 'LIVE',
"allow_comments" BOOLEAN NOT NULL DEFAULT true,
"closes_at" TIMESTAMP(3),
"close_threshold" INTEGER,
"auto_close_job_id" TEXT,
"is_private" BOOLEAN NOT NULL DEFAULT false,
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "straw_polls_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "straw_poll_options" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"label" VARCHAR(500) NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "straw_poll_options_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "straw_poll_votes" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"option_id" TEXT NOT NULL,
"user_id" TEXT,
"voter_name" VARCHAR(100),
"voter_token" TEXT,
"voter_ip" TEXT,
"contact_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "straw_poll_votes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "straw_poll_comments" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"user_id" TEXT,
"author_name" VARCHAR(100) NOT NULL,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "straw_poll_comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "straw_poll_challenges" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"challenger_user_id" TEXT NOT NULL,
"challenged_user_id" TEXT NOT NULL,
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "straw_poll_challenges_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "straw_polls_slug_key" ON "straw_polls"("slug");
-- CreateIndex
CREATE INDEX "straw_polls_created_by_user_id_idx" ON "straw_polls"("created_by_user_id");
-- CreateIndex
CREATE INDEX "straw_polls_status_idx" ON "straw_polls"("status");
-- CreateIndex
CREATE INDEX "straw_poll_options_poll_id_idx" ON "straw_poll_options"("poll_id");
-- CreateIndex
CREATE INDEX "straw_poll_votes_poll_id_idx" ON "straw_poll_votes"("poll_id");
-- CreateIndex
CREATE INDEX "straw_poll_votes_option_id_idx" ON "straw_poll_votes"("option_id");
-- CreateIndex
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_user_id_key" ON "straw_poll_votes"("poll_id", "user_id");
-- CreateIndex
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_voter_token_key" ON "straw_poll_votes"("poll_id", "voter_token");
-- CreateIndex
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_voter_ip_key" ON "straw_poll_votes"("poll_id", "voter_ip");
-- CreateIndex
CREATE INDEX "straw_poll_comments_poll_id_idx" ON "straw_poll_comments"("poll_id");
-- CreateIndex
CREATE UNIQUE INDEX "straw_poll_challenges_poll_id_challenger_user_id_challenged_key" ON "straw_poll_challenges"("poll_id", "challenger_user_id", "challenged_user_id");
-- AddForeignKey
ALTER TABLE "straw_polls" ADD CONSTRAINT "straw_polls_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_options" ADD CONSTRAINT "straw_poll_options_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "straw_poll_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_comments" ADD CONSTRAINT "straw_poll_comments_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_comments" ADD CONSTRAINT "straw_poll_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_challenger_user_id_fkey" FOREIGN KEY ("challenger_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_challenged_user_id_fkey" FOREIGN KEY ("challenged_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@@ -466,6 +466,32 @@ async function main() {
title: 'Vote on a Meeting Time',
},
},
{
id: 'default-straw-poll-inline',
type: 'straw-poll-inline',
label: 'Straw Poll (Inline)',
category: 'Influence',
sortOrder: 18,
schema: {
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
},
defaults: {
pollSlug: '',
},
},
{
id: 'default-straw-poll-card',
type: 'straw-poll-card',
label: 'Straw Poll (Card)',
category: 'Influence',
sortOrder: 19,
schema: {
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
},
defaults: {
pollSlug: '',
},
},
];
for (const block of defaultBlocks) {

View File

@@ -0,0 +1,123 @@
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 };

View File

@@ -0,0 +1,112 @@
import type { Response } from 'express';
import { logger } from '../../utils/logger';
interface PollSSEClient {
id: string;
res: Response;
connectedAt: Date;
}
/** Poll-level SSE manager keyed by slug (supports anonymous viewers) */
class PollSSEService {
private clients = new Map<string, PollSSEClient[]>(); // pollSlug -> connections
private heartbeatInterval: NodeJS.Timeout | null = null;
private static MAX_CONNECTIONS_PER_POLL = 200;
startHeartbeat() {
if (this.heartbeatInterval) return;
this.heartbeatInterval = setInterval(() => {
let total = 0;
for (const [, clients] of this.clients) {
for (const client of clients) {
try {
client.res.write(': heartbeat\n\n');
total++;
} catch {
this.removeClient(client.id);
}
}
}
if (total > 0) {
logger.debug(`Poll SSE heartbeat sent to ${total} clients`);
}
}, 30_000);
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
addClient(slug: string, res: Response): string | null {
const existing = this.clients.get(slug) ?? [];
if (existing.length >= PollSSEService.MAX_CONNECTIONS_PER_POLL) {
return null; // Reject — too many connections for this poll
}
const id = `${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const client: PollSSEClient = { id, res, connectedAt: new Date() };
if (!this.clients.has(slug)) {
this.clients.set(slug, []);
}
this.clients.get(slug)!.push(client);
logger.debug(`Poll SSE client connected: ${id} (poll: ${slug})`);
return id;
}
removeClient(connectionId: string) {
for (const [slug, clients] of this.clients) {
const idx = clients.findIndex((c) => c.id === connectionId);
if (idx >= 0) {
clients.splice(idx, 1);
if (clients.length === 0) {
this.clients.delete(slug);
}
logger.debug(`Poll SSE client disconnected: ${connectionId} (poll: ${slug})`);
return;
}
}
}
/** Broadcast event to all viewers of a poll */
broadcast(slug: string, event: string, data: unknown) {
const clients = this.clients.get(slug);
if (!clients || clients.length === 0) return;
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of clients) {
try {
client.res.write(payload);
} catch {
this.removeClient(client.id);
}
}
}
getConnectionCount(slug?: string): number {
if (slug) return this.clients.get(slug)?.length ?? 0;
let count = 0;
for (const clients of this.clients.values()) {
count += clients.length;
}
return count;
}
closeAll() {
this.stopHeartbeat();
for (const [, clients] of this.clients) {
for (const client of clients) {
try { client.res.end(); } catch {}
}
}
this.clients.clear();
logger.info('Poll SSE: All connections closed');
}
}
export const pollSseService = new PollSSEService();

View File

@@ -0,0 +1,30 @@
import { Router, Request, Response, NextFunction } from 'express';
import { strawPollsService } from './polls.service';
import { redis } from '../../config/redis';
const widgetRouter = Router();
// Lightweight JSON for MkDocs widget embeds (cached 60s)
widgetRouter.get('/widget/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const cacheKey = `straw-poll-widget:${slug}`;
// Check Redis cache
const cached = await redis.get(cacheKey);
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
}
const poll = await strawPollsService.findBySlugWidget(slug);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
// Cache for 60 seconds
await redis.set(cacheKey, JSON.stringify(poll), 'EX', 60);
res.set('X-Cache', 'MISS');
res.json(poll);
} catch (err) { next(err); }
});
export { widgetRouter as strawPollWidgetRouter };

View File

@@ -0,0 +1,37 @@
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
export const strawPollVoteRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 30,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:straw-poll-vote:',
}),
message: {
error: {
message: 'Too many vote submissions, please try again later',
code: 'STRAW_POLL_VOTE_RATE_LIMIT_EXCEEDED',
},
},
});
export const strawPollCommentRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:straw-poll-comment:',
}),
message: {
error: {
message: 'Too many comments, please try again later',
code: 'STRAW_POLL_COMMENT_RATE_LIMIT_EXCEEDED',
},
},
});

View File

@@ -0,0 +1,121 @@
import { Router, Request, Response, NextFunction } from 'express';
import { strawPollsService } from './polls.service';
import {
createStrawPollSchema,
updateStrawPollSchema,
listStrawPollsSchema,
generateLinksSchema,
} from './polls.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { POLLS_ROLES } from '../../utils/roles';
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...POLLS_ROLES));
// List polls
adminRouter.get('/', validate(listStrawPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await strawPollsService.findAll(req.query as any);
res.json(result);
} catch (err) { next(err); }
});
// Get poll detail
adminRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await strawPollsService.findById(id);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
res.json(poll);
} catch (err) { next(err); }
});
// Create poll
adminRouter.post('/', validate(createStrawPollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.create(req.body, req.user!.id);
res.status(201).json(poll);
} catch (err) { next(err); }
});
// Update poll
adminRouter.put('/:id', validate(updateStrawPollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await strawPollsService.update(id, req.body);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
res.json(poll);
} catch (err) { next(err); }
});
// Delete poll
adminRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await strawPollsService.delete(id);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
res.json({ success: true });
} catch (err) { next(err); }
});
// Activate (DRAFT -> ACTIVE)
adminRouter.post('/:id/activate', async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.activate(req.params.id as string);
res.json(poll);
} catch (err) { next(err); }
});
// Close (ACTIVE -> CLOSED)
adminRouter.post('/:id/close', async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.closePoll(req.params.id as string);
if (!poll) return res.status(400).json({ error: 'Poll cannot be closed (not active)' });
res.json(poll);
} catch (err) { next(err); }
});
// Reopen (CLOSED -> ACTIVE)
adminRouter.post('/:id/reopen', async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.reopenPoll(req.params.id as string);
res.json(poll);
} catch (err) { next(err); }
});
// Archive (CLOSED -> ARCHIVED)
adminRouter.post('/:id/archive', async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.archivePoll(req.params.id as string);
res.json(poll);
} catch (err) { next(err); }
});
// Delete a vote (moderation)
adminRouter.delete('/:id/votes/:voteId', async (req: Request, res: Response, next: NextFunction) => {
try {
await strawPollsService.deleteVote(req.params.id as string, req.params.voteId as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// Delete a comment (moderation)
adminRouter.delete('/:id/comments/:commentId', async (req: Request, res: Response, next: NextFunction) => {
try {
await strawPollsService.deleteComment(req.params.id as string, req.params.commentId as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// Generate voting links (TOKEN_GATED)
adminRouter.post('/:id/generate-links', validate(generateLinksSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await strawPollsService.generateVotingTokens(req.params.id as string, req.body.count);
res.json(result);
} catch (err) { next(err); }
});
export { adminRouter as strawPollAdminRouter };

View File

@@ -0,0 +1,67 @@
import { z } from 'zod';
import { StrawPollType, StrawPollStatus, StrawPollIdentityMode, StrawPollResultVisibility } from '@prisma/client';
export const createStrawPollSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional(),
type: z.nativeEnum(StrawPollType),
identityMode: z.nativeEnum(StrawPollIdentityMode).optional().default('ANONYMOUS'),
resultVisibility: z.nativeEnum(StrawPollResultVisibility).optional().default('LIVE'),
allowComments: z.boolean().optional().default(true),
closesAt: z.string().datetime().optional(),
closeThreshold: z.number().int().min(1).max(100000).nullable().optional(),
isPrivate: z.boolean().optional().default(false),
options: z.array(z.object({
label: z.string().min(1, 'Option label is required').max(500),
})).min(2, 'At least 2 options required').max(20, 'Maximum 20 options').optional(),
});
export const updateStrawPollSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
identityMode: z.nativeEnum(StrawPollIdentityMode).optional(),
resultVisibility: z.nativeEnum(StrawPollResultVisibility).optional(),
allowComments: z.boolean().optional(),
closesAt: z.string().datetime().nullable().optional(),
closeThreshold: z.number().int().min(1).max(100000).nullable().optional(),
isPrivate: z.boolean().optional(),
options: z.array(z.object({
id: z.string().optional(),
label: z.string().min(1).max(500),
})).min(2).max(20).optional(),
});
export const submitStrawPollVoteSchema = z.object({
optionId: z.string().min(1, 'Option is required'),
voterName: z.string().min(1).max(100).optional(),
voterToken: z.string().optional(),
});
export const submitStrawPollCommentSchema = z.object({
authorName: z.string().min(1, 'Name is required').max(100),
content: z.string().min(1, 'Comment is required').max(2000),
});
export const listStrawPollsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.nativeEnum(StrawPollStatus).optional(),
type: z.nativeEnum(StrawPollType).optional(),
});
export const challengeVoteSchema = z.object({
challengedUserId: z.string().min(1, 'User ID is required'),
});
export const generateLinksSchema = z.object({
count: z.number().int().min(1).max(500).default(10),
});
export type CreateStrawPollInput = z.infer<typeof createStrawPollSchema>;
export type UpdateStrawPollInput = z.infer<typeof updateStrawPollSchema>;
export type SubmitStrawPollVoteInput = z.infer<typeof submitStrawPollVoteSchema>;
export type SubmitStrawPollCommentInput = z.infer<typeof submitStrawPollCommentSchema>;
export type ListStrawPollsInput = z.infer<typeof listStrawPollsSchema>;
export type ChallengeVoteInput = z.infer<typeof challengeVoteSchema>;
export type GenerateLinksInput = z.infer<typeof generateLinksSchema>;

View File

@@ -0,0 +1,656 @@
import crypto from 'crypto';
import { Prisma, StrawPollStatus, StrawPollType } from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { generateSlug } from '../../utils/slug';
import { pollSseService } from './polls-sse.service';
import type {
CreateStrawPollInput,
UpdateStrawPollInput,
SubmitStrawPollVoteInput,
SubmitStrawPollCommentInput,
ListStrawPollsInput,
} from './polls.schemas';
function generateVoterToken(): string {
return crypto.randomBytes(18).toString('base64url').slice(0, 24);
}
// Select configs for different contexts
const pollListSelect = {
id: true,
slug: true,
title: true,
type: true,
status: true,
identityMode: true,
resultVisibility: true,
isPrivate: true,
closesAt: true,
closeThreshold: true,
allowComments: true,
createdByUserId: true,
createdAt: true,
updatedAt: true,
_count: { select: { votes: true, comments: true, options: true } },
} satisfies Prisma.StrawPollSelect;
const pollDetailSelect = {
...pollListSelect,
description: true,
autoCloseJobId: true,
options: { orderBy: { sortOrder: 'asc' as const }, include: { _count: { select: { votes: true } } } },
votes: {
select: {
id: true, optionId: true, userId: true, voterName: true, createdAt: true,
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'desc' as const },
},
comments: {
select: { id: true, authorName: true, content: true, userId: true, createdAt: true },
orderBy: { createdAt: 'desc' as const },
},
createdBy: { select: { id: true, name: true, email: true } },
};
const publicPollSelect = {
id: true,
slug: true,
title: true,
description: true,
type: true,
status: true,
identityMode: true,
resultVisibility: true,
allowComments: true,
isPrivate: true,
closesAt: true,
createdByUserId: true,
createdAt: true,
options: {
select: { id: true, label: true, sortOrder: true, _count: { select: { votes: true } } },
orderBy: { sortOrder: 'asc' as const },
},
_count: { select: { votes: true, comments: true } },
comments: {
select: { id: true, authorName: true, content: true, createdAt: true },
orderBy: { createdAt: 'desc' as const },
},
createdBy: { select: { name: true } },
};
class StrawPollsService {
// ===== Admin CRUD =====
async findAll(filters: ListStrawPollsInput) {
const { page, limit, search, status, type } = filters;
const where: Prisma.StrawPollWhereInput = {};
if (status) where.status = status;
if (type) where.type = type;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const [polls, total] = await Promise.all([
prisma.strawPoll.findMany({
where,
select: pollListSelect,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.strawPoll.count({ where }),
]);
return { polls, total, page, limit };
}
async findById(id: string) {
return prisma.strawPoll.findUnique({ where: { id }, select: pollDetailSelect });
}
async create(data: CreateStrawPollInput, userId: string) {
const slug = generateSlug(data.title);
// For YES_NO_ABSTAIN, auto-create the three fixed options
const options = data.type === StrawPollType.YES_NO_ABSTAIN
? [
{ label: 'Yes', sortOrder: 0 },
{ label: 'No', sortOrder: 1 },
{ label: 'Abstain', sortOrder: 2 },
]
: (data.options ?? []).map((opt, i) => ({ label: opt.label, sortOrder: i }));
if (data.type === StrawPollType.SINGLE_CHOICE && options.length < 2) {
throw new Error('SINGLE_CHOICE polls require at least 2 options');
}
const poll = await prisma.strawPoll.create({
data: {
slug,
title: data.title,
description: data.description,
type: data.type,
identityMode: data.identityMode,
resultVisibility: data.resultVisibility,
allowComments: data.allowComments,
closesAt: data.closesAt ? new Date(data.closesAt) : undefined,
closeThreshold: data.closeThreshold,
isPrivate: data.isPrivate,
createdByUserId: userId,
options: { create: options },
},
select: pollDetailSelect,
});
// Schedule auto-close if closesAt is set
if (poll.closesAt) {
try {
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
const jobId = await pollAutoCloseQueueService.scheduleJob(poll.id, poll.closesAt);
if (jobId) {
await prisma.strawPoll.update({ where: { id: poll.id }, data: { autoCloseJobId: jobId } });
}
} catch (err) {
logger.error('Failed to schedule auto-close job', { error: err, pollId: poll.id });
}
}
return poll;
}
async update(id: string, data: UpdateStrawPollInput) {
const existing = await prisma.strawPoll.findUnique({ where: { id } });
if (!existing) return null;
// Handle options update for SINGLE_CHOICE polls
if (data.options && existing.type === StrawPollType.SINGLE_CHOICE) {
// Delete removed options, update existing, create new
const existingOptions = await prisma.strawPollOption.findMany({ where: { pollId: id } });
const incomingIds = data.options.filter(o => o.id).map(o => o.id!);
const toDelete = existingOptions.filter(o => !incomingIds.includes(o.id));
await prisma.$transaction([
// Delete removed options (and their votes)
...toDelete.map(o => prisma.strawPollOption.delete({ where: { id: o.id } })),
// Upsert remaining
...data.options.map((opt, i) =>
opt.id
? prisma.strawPollOption.update({ where: { id: opt.id }, data: { label: opt.label, sortOrder: i } })
: prisma.strawPollOption.create({ data: { pollId: id, label: opt.label, sortOrder: i } })
),
]);
}
const { options: _, ...updateData } = data;
const poll = await prisma.strawPoll.update({
where: { id },
data: {
...updateData,
closesAt: data.closesAt === null ? null : data.closesAt ? new Date(data.closesAt) : undefined,
},
select: pollDetailSelect,
});
// Reschedule auto-close if closesAt changed
if (data.closesAt !== undefined) {
try {
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
if (existing.autoCloseJobId) await pollAutoCloseQueueService.cancelJob(existing.id);
if (poll.closesAt) {
const jobId = await pollAutoCloseQueueService.scheduleJob(poll.id, poll.closesAt);
if (jobId) await prisma.strawPoll.update({ where: { id }, data: { autoCloseJobId: jobId } });
}
} catch (err) {
logger.error('Failed to reschedule auto-close job', { error: err, pollId: id });
}
}
return poll;
}
async delete(id: string) {
const existing = await prisma.strawPoll.findUnique({ where: { id } });
if (!existing) return null;
// Cancel auto-close job if scheduled
if (existing.autoCloseJobId) {
try {
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
await pollAutoCloseQueueService.cancelJob(existing.id);
} catch {}
}
return prisma.strawPoll.delete({ where: { id } });
}
// ===== Lifecycle transitions =====
async activate(id: string) {
return prisma.strawPoll.update({
where: { id, status: StrawPollStatus.DRAFT },
data: { status: StrawPollStatus.ACTIVE },
select: pollDetailSelect,
});
}
async closePoll(id: string) {
const poll = await prisma.strawPoll.updateMany({
where: { id, status: StrawPollStatus.ACTIVE },
data: { status: StrawPollStatus.CLOSED },
});
if (poll.count === 0) return null;
const closed = await prisma.strawPoll.findUnique({
where: { id },
select: { slug: true, title: true, votes: { select: { userId: true }, where: { userId: { not: null } } } },
});
// Broadcast poll closed via SSE
if (closed) {
pollSseService.broadcast(closed.slug, 'poll_closed', { pollId: id });
}
// Notify authenticated voters (fire-and-forget)
if (closed?.votes) {
this.notifyVotersPollClosed(id, closed.title, closed.votes.map(v => v.userId!)).catch(() => {});
}
return prisma.strawPoll.findUnique({ where: { id }, select: pollDetailSelect });
}
async reopenPoll(id: string) {
return prisma.strawPoll.update({
where: { id, status: StrawPollStatus.CLOSED },
data: { status: StrawPollStatus.ACTIVE },
select: pollDetailSelect,
});
}
async archivePoll(id: string) {
return prisma.strawPoll.update({
where: { id, status: StrawPollStatus.CLOSED },
data: { status: StrawPollStatus.ARCHIVED },
select: pollDetailSelect,
});
}
// ===== Public endpoints =====
async findAllPublic(filters: ListStrawPollsInput) {
const { page, limit, search } = filters;
const where: Prisma.StrawPollWhereInput = {
status: StrawPollStatus.ACTIVE,
isPrivate: false,
};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const [polls, total] = await Promise.all([
prisma.strawPoll.findMany({
where,
select: {
id: true,
slug: true,
title: true,
description: true,
type: true,
status: true,
closesAt: true,
createdAt: true,
_count: { select: { votes: true, options: true } },
createdBy: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.strawPoll.count({ where }),
]);
return { polls, total, page, limit };
}
async findBySlugPublic(slug: string, userId?: string, voterToken?: string, voterIp?: string) {
const poll = await prisma.strawPoll.findUnique({
where: { slug },
select: publicPollSelect,
});
if (!poll) return null;
if (poll.isPrivate && !userId) {
// Return limited metadata for private polls when not authenticated
return {
id: poll.id,
slug: poll.slug,
title: poll.title,
type: poll.type,
status: poll.status,
isPrivate: true,
requiresAuth: true,
};
}
// Determine if results should be shown
const showResults = this.shouldShowResults(poll, userId, voterToken, voterIp);
// Strip vote counts if results are hidden
const options = poll.options.map(opt => ({
id: opt.id,
label: opt.label,
sortOrder: opt.sortOrder,
voteCount: showResults ? opt._count.votes : undefined,
}));
// Check if the requester has already voted
const hasVoted = await this.checkHasVoted(poll.id, userId, voterToken, voterIp);
return {
...poll,
options,
totalVotes: showResults ? poll._count.votes : undefined,
showResults,
hasVoted,
};
}
async findBySlugWidget(slug: string) {
const poll = await prisma.strawPoll.findUnique({
where: { slug },
select: {
id: true,
slug: true,
title: true,
type: true,
status: true,
resultVisibility: true,
identityMode: true,
options: {
select: { id: true, label: true, sortOrder: true, _count: { select: { votes: true } } },
orderBy: { sortOrder: 'asc' },
},
_count: { select: { votes: true } },
},
});
if (!poll) return null;
// Widget always returns counts for LIVE/PUBLIC_ALWAYS; otherwise omit
const showCounts = poll.resultVisibility === 'LIVE' || poll.resultVisibility === 'PUBLIC_ALWAYS';
return {
id: poll.id,
slug: poll.slug,
title: poll.title,
type: poll.type,
status: poll.status,
identityMode: poll.identityMode,
totalVotes: showCounts ? poll._count.votes : 0,
options: poll.options.map(o => ({
id: o.id,
label: o.label,
sortOrder: o.sortOrder,
voteCount: showCounts ? o._count.votes : 0,
})),
};
}
// ===== Voting =====
async submitVote(
slug: string,
data: SubmitStrawPollVoteInput,
userId?: string,
clientIp?: string,
) {
const poll = await prisma.strawPoll.findUnique({
where: { slug },
select: { id: true, status: true, identityMode: true, closeThreshold: true, slug: true, _count: { select: { votes: true } } },
});
if (!poll) throw new Error('Poll not found');
if (poll.status !== StrawPollStatus.ACTIVE) throw new Error('Poll is not active');
// Validate option exists
const option = await prisma.strawPollOption.findFirst({
where: { id: data.optionId, pollId: poll.id },
});
if (!option) throw new Error('Invalid option');
// Enforce identity mode
const { identityMode } = poll;
if (identityMode === 'AUTHENTICATED' && !userId) {
throw new Error('Authentication required to vote');
}
if (identityMode === 'TOKEN_GATED' && !data.voterToken) {
throw new Error('A voting token is required');
}
// Determine dedup key
let voterToken = data.voterToken || null;
const voterIp = (identityMode === 'ANONYMOUS' && !userId) ? clientIp : null;
// For anonymous/mixed without a token, generate one
if (!userId && !voterToken && identityMode !== 'TOKEN_GATED') {
voterToken = generateVoterToken();
}
// Upsert vote: one vote per poll per voter
let vote;
if (userId) {
vote = await prisma.strawPollVote.upsert({
where: { pollId_userId: { pollId: poll.id, userId } },
create: {
pollId: poll.id,
optionId: data.optionId,
userId,
voterName: data.voterName,
voterToken,
},
update: { optionId: data.optionId, updatedAt: new Date() },
});
} else if (voterToken) {
vote = await prisma.strawPollVote.upsert({
where: { pollId_voterToken: { pollId: poll.id, voterToken } },
create: {
pollId: poll.id,
optionId: data.optionId,
voterName: data.voterName,
voterToken,
voterIp,
},
update: { optionId: data.optionId, updatedAt: new Date() },
});
} else if (voterIp) {
vote = await prisma.strawPollVote.upsert({
where: { pollId_voterIp: { pollId: poll.id, voterIp } },
create: {
pollId: poll.id,
optionId: data.optionId,
voterName: data.voterName,
voterIp,
},
update: { optionId: data.optionId, updatedAt: new Date() },
});
} else {
throw new Error('Unable to identify voter');
}
// Get updated counts for SSE broadcast
const optionCounts = await prisma.strawPollOption.findMany({
where: { pollId: poll.id },
select: { id: true, _count: { select: { votes: true } } },
orderBy: { sortOrder: 'asc' },
});
const totalVotes = optionCounts.reduce((sum, o) => sum + o._count.votes, 0);
// Broadcast vote update via SSE
pollSseService.broadcast(poll.slug, 'vote_update', {
optionCounts: optionCounts.map(o => ({ optionId: o.id, count: o._count.votes })),
totalVotes,
});
// Check auto-close threshold
if (poll.closeThreshold && totalVotes >= poll.closeThreshold) {
this.closePoll(poll.id).catch(err =>
logger.error('Auto-close by threshold failed', { error: err, pollId: poll.id })
);
}
return { voteId: vote.id, voterToken: voterToken || undefined };
}
// ===== Comments =====
async addComment(slug: string, data: SubmitStrawPollCommentInput, userId?: string) {
const poll = await prisma.strawPoll.findUnique({
where: { slug },
select: { id: true, allowComments: true, slug: true },
});
if (!poll) throw new Error('Poll not found');
if (!poll.allowComments) throw new Error('Comments are disabled');
const comment = await prisma.strawPollComment.create({
data: {
pollId: poll.id,
userId,
authorName: data.authorName,
content: data.content,
},
select: { id: true, authorName: true, content: true, createdAt: true },
});
pollSseService.broadcast(poll.slug, 'comment_added', comment);
return comment;
}
async deleteComment(pollId: string, commentId: string) {
return prisma.strawPollComment.delete({ where: { id: commentId, pollId } });
}
// ===== Vote moderation =====
async deleteVote(pollId: string, voteId: string) {
return prisma.strawPollVote.delete({ where: { id: voteId, pollId } });
}
// ===== Challenges =====
async challengeFriend(pollId: string, challengerUserId: string, challengedUserId: string) {
const poll = await prisma.strawPoll.findUnique({ where: { id: pollId }, select: { slug: true, title: true } });
if (!poll) throw new Error('Poll not found');
const challenge = await prisma.strawPollChallenge.create({
data: { pollId, challengerUserId, challengedUserId },
});
// Send notification (fire-and-forget)
this.sendChallengeNotification(challengedUserId, challengerUserId, poll.slug, poll.title).catch(() => {});
return challenge;
}
// ===== TOKEN_GATED link generation =====
async generateVotingTokens(pollId: string, count: number) {
const poll = await prisma.strawPoll.findUnique({ where: { id: pollId }, select: { identityMode: true, slug: true } });
if (!poll) throw new Error('Poll not found');
if (poll.identityMode !== 'TOKEN_GATED') throw new Error('Poll is not token-gated');
const tokens: string[] = [];
for (let i = 0; i < count; i++) {
tokens.push(generateVoterToken());
}
return { tokens, slug: poll.slug };
}
// ===== Private helpers =====
private shouldShowResults(
poll: { resultVisibility: string; status: string; createdByUserId: string; id: string },
userId?: string,
voterToken?: string,
voterIp?: string,
): boolean {
switch (poll.resultVisibility) {
case 'LIVE':
case 'PUBLIC_ALWAYS':
return true;
case 'AFTER_CLOSE':
return poll.status === 'CLOSED' || poll.status === 'ARCHIVED';
case 'CREATOR_ONLY':
return !!userId && userId === poll.createdByUserId;
case 'AFTER_VOTE':
// Will be checked after hasVoted query — return true optimistically,
// actual filtering happens in findBySlugPublic
return true; // placeholder; refined by hasVoted check in caller
default:
return false;
}
}
private async checkHasVoted(pollId: string, userId?: string, voterToken?: string, voterIp?: string): Promise<boolean> {
if (userId) {
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_userId: { pollId, userId } } });
return !!vote;
}
if (voterToken) {
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_voterToken: { pollId, voterToken } } });
return !!vote;
}
if (voterIp) {
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_voterIp: { pollId, voterIp } } });
return !!vote;
}
return false;
}
private async notifyVotersPollClosed(pollId: string, title: string, voterUserIds: string[]) {
try {
const { notificationService } = await import('../social/notification.service');
for (const userId of voterUserIds) {
await notificationService.createNotification(
userId,
'poll_closed' as any,
'Poll Closed',
`The poll "${title}" has closed. Check the results!`,
{ pollId },
);
}
} catch (err) {
logger.error('Failed to send poll closed notifications', { error: err, pollId });
}
}
private async sendChallengeNotification(
challengedUserId: string,
challengerUserId: string,
pollSlug: string,
pollTitle: string,
) {
try {
const challenger = await prisma.user.findUnique({ where: { id: challengerUserId }, select: { name: true } });
const { notificationService } = await import('../social/notification.service');
await notificationService.createNotification(
challengedUserId,
'poll_challenge' as any,
'Poll Challenge',
`${challenger?.name || 'Someone'} challenged you to vote on "${pollTitle}"`,
{ pollSlug, challengerUserId },
);
} catch (err) {
logger.error('Failed to send challenge notification', { error: err });
}
}
}
export const strawPollsService = new StrawPollsService();

View File

@@ -58,6 +58,7 @@ export const updateSiteSettingsSchema = z.object({
enableMeet: z.boolean().optional(),
enableMeetingPlanner: z.boolean().optional(),
enableTicketedEvents: z.boolean().optional(),
enablePolls: z.boolean().optional(),
enableSocialCalendar: z.boolean().optional(),
enableDocsCollaboration: z.boolean().optional(),
requireEventApproval: z.boolean().optional(),

View File

@@ -5,7 +5,7 @@ import { friendshipService } from './friendship.service';
/** A unified feed item representing any activity type */
export interface FeedItem {
id: string;
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed';
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed' | 'poll_voted';
userId: string;
userName: string | null;
userEmail: string;
@@ -56,7 +56,7 @@ export const feedService = {
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
// Query all activity types in parallel
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges] = await Promise.all([
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges, pollVotes] = await Promise.all([
this.getShiftSignupActivities(visibleFriendIds, since),
this.getCampaignEmailActivities(visibleFriendIds, since),
this.getCanvassSessionActivities(visibleFriendIds, since),
@@ -65,6 +65,7 @@ export const feedService = {
this.getSpotlightActivities(since),
this.getReferralActivities(visibleFriendIds, since),
this.getChallengeActivities(since),
this.getStrawPollVoteActivities(visibleFriendIds, since),
]);
// Merge and sort by timestamp descending
@@ -77,6 +78,7 @@ export const feedService = {
...spotlights,
...referrals,
...challenges,
...pollVotes,
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Cap total items
@@ -362,4 +364,34 @@ export const feedService = {
};
});
},
async getStrawPollVoteActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
const votes = await prisma.strawPollVote.findMany({
where: {
userId: { in: userIds },
createdAt: { gte: since },
},
include: {
user: { select: { id: true, name: true, email: true } },
poll: { select: { id: true, slug: true, title: true } },
option: { select: { label: true } },
},
orderBy: { createdAt: 'desc' },
take: 20,
});
return votes
.filter((v) => v.user)
.map((v) => ({
id: `poll_vote:${v.id}`,
type: 'poll_voted' as const,
userId: v.user!.id,
userName: v.user!.name,
userEmail: v.user!.email,
title: `Voted on "${v.poll.title}"`,
description: `Chose: ${v.option.label}`,
metadata: { pollId: v.poll.id, pollSlug: v.poll.slug },
timestamp: v.createdAt,
}));
},
};

View File

@@ -31,6 +31,18 @@ router.get('/campaigns/:campaignId/friends', async (req: Request, res: Response)
}
});
/** GET /api/social/integration/straw-polls/:pollId/friends */
router.get('/straw-polls/:pollId/friends', async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const pollId = req.params.pollId as string;
const result = await integrationService.getFriendsInStrawPoll(userId, pollId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message } });
}
});
/** GET /api/social/integration/map/friends */
router.get('/map/friends', async (req: Request, res: Response) => {
try {

View File

@@ -99,6 +99,38 @@ export const integrationService = {
};
},
/** Get friends who voted on a straw poll */
async getFriendsInStrawPoll(userId: string, pollId: string) {
const friendIds = await friendshipService.getFriendIds(userId);
if (friendIds.length === 0) return { friends: [], count: 0 };
const hiddenIds = await this.getHiddenActivityUserIds(friendIds);
const visibleIds = friendIds.filter((id) => !hiddenIds.has(id));
if (visibleIds.length === 0) return { friends: [], count: 0 };
const votes = await prisma.strawPollVote.findMany({
where: {
pollId,
userId: { in: visibleIds },
},
distinct: ['userId'],
include: {
user: { select: { id: true, name: true, email: true } },
},
});
return {
friends: votes
.filter((v) => v.user)
.map((v) => ({
id: v.user!.id,
name: v.user!.name,
email: v.user!.email,
})),
count: votes.filter((v) => v.user).length,
};
},
/** Helper: get user IDs that have showInFriendActivity disabled */
async getHiddenActivityUserIds(userIds: string[]): Promise<Set<string>> {
const hidden = await prisma.privacySettings.findMany({

View File

@@ -25,6 +25,10 @@ const TYPE_TO_PREF: Record<string, string> = {
shift_cancelled: 'enableSystemUpdates',
canvass_session_summary: 'enableSystemUpdates',
reengagement: 'enableSystemUpdates',
// Straw poll notification types
poll_closed: 'enableSystemUpdates',
poll_results_available: 'enableSystemUpdates',
poll_challenge: 'enableFriendRequests',
};
export const notificationService = {

View File

@@ -0,0 +1,129 @@
import { Queue, Worker, type Job } from 'bullmq';
import { env } from '../config/env';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
interface PollAutoCloseJobData {
pollId: string;
}
class PollAutoCloseQueueService {
private queue: Queue;
private worker: Worker | null = null;
constructor() {
this.queue = new Queue('straw-poll-auto-close', {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 500 },
removeOnFail: { age: 30 * 24 * 60 * 60 },
},
});
}
startWorker() {
this.worker = new Worker(
'straw-poll-auto-close',
async (job: Job<PollAutoCloseJobData>) => {
const { pollId } = job.data;
logger.info(`Processing straw poll auto-close job ${job.id}`, { pollId });
// Dynamic import to avoid circular dependency
const { strawPollsService } = await import(
'../modules/polls/polls.service'
);
await strawPollsService.closePoll(pollId);
},
{
connection: { url: env.REDIS_URL },
concurrency: 1,
}
);
this.worker.on('completed', (job) => {
logger.info(`Straw poll auto-close job ${job.id} completed`);
});
this.worker.on('failed', (job, err) => {
logger.error(`Straw poll auto-close job ${job?.id} failed: ${err.message}`);
});
logger.info('Straw poll auto-close queue worker started');
this.recoverOnStartup().catch((err) =>
logger.error('Straw poll auto-close startup recovery failed', { error: err })
);
}
async scheduleJob(pollId: string, deadline: Date): Promise<string | null> {
const delay = deadline.getTime() - Date.now();
if (delay <= 0) {
const job = await this.queue.add(`close-${pollId}`, { pollId }, {
jobId: `poll-close-${pollId}`,
});
return job.id ?? null;
}
const job = await this.queue.add(`close-${pollId}`, { pollId }, {
delay,
jobId: `poll-close-${pollId}`,
});
logger.info(`Scheduled straw poll auto-close for ${deadline.toISOString()}`, {
pollId,
jobId: job.id,
delayMs: delay,
});
return job.id ?? null;
}
async cancelJob(pollId: string): Promise<void> {
try {
const jobs = await this.queue.getJobs(['delayed', 'waiting']);
for (const job of jobs) {
if (job.data.pollId === pollId) {
await job.remove();
logger.info(`Cancelled auto-close job for straw poll ${pollId}`, { jobId: job.id });
}
}
} catch (error) {
logger.error('Failed to cancel straw poll auto-close job', { error, pollId });
}
}
private async recoverOnStartup() {
const activePolls = await prisma.strawPoll.findMany({
where: {
status: 'ACTIVE',
closesAt: { not: null },
},
select: { id: true, closesAt: true },
});
for (const poll of activePolls) {
if (!poll.closesAt) continue;
const jobId = await this.scheduleJob(poll.id, poll.closesAt);
if (jobId) {
await prisma.strawPoll.update({
where: { id: poll.id },
data: { autoCloseJobId: jobId },
}).catch(() => {});
}
}
if (activePolls.length > 0) {
logger.info(`Recovered ${activePolls.length} straw poll auto-close jobs on startup`);
}
}
async close() {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
logger.info('Straw poll auto-close queue closed');
}
}
export const pollAutoCloseQueueService = new PollAutoCloseQueueService();

View File

@@ -10,6 +10,7 @@ const ROLE_PRIORITY: Record<string, number> = {
PAYMENTS_ADMIN: 4,
EVENTS_ADMIN: 4,
SOCIAL_ADMIN: 4,
POLLS_ADMIN: 4,
USER: 2,
TEMP: 1,
};
@@ -25,6 +26,7 @@ export const ADMIN_ROLES: UserRole[] = [
UserRole.PAYMENTS_ADMIN,
UserRole.EVENTS_ADMIN,
UserRole.SOCIAL_ADMIN,
UserRole.POLLS_ADMIN,
];
// Module-specific role groups
@@ -38,6 +40,7 @@ export const EVENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.EVENTS_A
export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN];
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
export const POLLS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.POLLS_ADMIN, UserRole.INFLUENCE_ADMIN];
/** Check if the user has any of the specified roles */
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {