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:
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
123
api/src/modules/polls/polls-public.routes.ts
Normal file
123
api/src/modules/polls/polls-public.routes.ts
Normal 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 };
|
||||
112
api/src/modules/polls/polls-sse.service.ts
Normal file
112
api/src/modules/polls/polls-sse.service.ts
Normal 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();
|
||||
30
api/src/modules/polls/polls-widget.routes.ts
Normal file
30
api/src/modules/polls/polls-widget.routes.ts
Normal 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 };
|
||||
37
api/src/modules/polls/polls.rate-limits.ts
Normal file
37
api/src/modules/polls/polls.rate-limits.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
121
api/src/modules/polls/polls.routes.ts
Normal file
121
api/src/modules/polls/polls.routes.ts
Normal 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 };
|
||||
67
api/src/modules/polls/polls.schemas.ts
Normal file
67
api/src/modules/polls/polls.schemas.ts
Normal 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>;
|
||||
656
api/src/modules/polls/polls.service.ts
Normal file
656
api/src/modules/polls/polls.service.ts
Normal 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();
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
129
api/src/services/poll-auto-close-queue.service.ts
Normal file
129
api/src/services/poll-auto-close-queue.service.ts
Normal 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();
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user