From 902adce64601e9f26705323d99b5d7cf27312d6c Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Tue, 31 Mar 2026 10:16:56 -0600 Subject: [PATCH] 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 --- admin/src/App.tsx | 20 + admin/src/components/AppLayout.tsx | 4 + admin/src/components/FeatureGate.tsx | 3 +- admin/src/components/GrapesJSEditor.tsx | 34 + .../people/UserAccountStatusPanel.tsx | 1 + admin/src/components/polls/PollResults.tsx | 51 ++ admin/src/pages/SettingsPage.tsx | 3 + admin/src/pages/UsersPage.tsx | 1 + admin/src/pages/influence/StrawPollsPage.tsx | 412 +++++++++++ admin/src/pages/public/StrawPollPage.tsx | 293 ++++++++ admin/src/pages/public/StrawPollsListPage.tsx | 63 ++ admin/src/types/api.ts | 101 ++- .../migration.sql | 168 +++++ api/prisma/seed.ts | 26 + api/src/modules/polls/polls-public.routes.ts | 123 ++++ api/src/modules/polls/polls-sse.service.ts | 112 +++ api/src/modules/polls/polls-widget.routes.ts | 30 + api/src/modules/polls/polls.rate-limits.ts | 37 + api/src/modules/polls/polls.routes.ts | 121 ++++ api/src/modules/polls/polls.schemas.ts | 67 ++ api/src/modules/polls/polls.service.ts | 656 ++++++++++++++++++ api/src/modules/settings/settings.schemas.ts | 1 + api/src/modules/social/feed.service.ts | 36 +- api/src/modules/social/integration.routes.ts | 12 + api/src/modules/social/integration.service.ts | 32 + .../modules/social/notification.service.ts | 4 + .../services/poll-auto-close-queue.service.ts | 129 ++++ api/src/utils/roles.ts | 3 + mkdocs/docs/assets/js/straw-poll-widget.js | 226 ++++++ mkdocs/mkdocs.yml | 3 +- 30 files changed, 2766 insertions(+), 6 deletions(-) create mode 100644 admin/src/components/polls/PollResults.tsx create mode 100644 admin/src/pages/influence/StrawPollsPage.tsx create mode 100644 admin/src/pages/public/StrawPollPage.tsx create mode 100644 admin/src/pages/public/StrawPollsListPage.tsx create mode 100644 api/prisma/migrations/20260330100000_add_straw_polls/migration.sql create mode 100644 api/src/modules/polls/polls-public.routes.ts create mode 100644 api/src/modules/polls/polls-sse.service.ts create mode 100644 api/src/modules/polls/polls-widget.routes.ts create mode 100644 api/src/modules/polls/polls.rate-limits.ts create mode 100644 api/src/modules/polls/polls.routes.ts create mode 100644 api/src/modules/polls/polls.schemas.ts create mode 100644 api/src/modules/polls/polls.service.ts create mode 100644 api/src/services/poll-auto-close-queue.service.ts create mode 100644 mkdocs/docs/assets/js/straw-poll-widget.js diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 7f729f36..c81b5217 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -112,6 +112,7 @@ import { EVENTS_ROLES, SOCIAL_ROLES, SYSTEM_ROLES, + POLLS_ROLES, } from '@/types/api'; import { isAdmin } from '@/utils/roles'; import QuickJoinPage from '@/pages/public/QuickJoinPage'; @@ -132,6 +133,7 @@ import ReferralAdminPage from '@/pages/social/ReferralAdminPage'; import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage'; import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage'; import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage'; +import StrawPollsPage from '@/pages/influence/StrawPollsPage'; import ReferralsPage from '@/pages/volunteer/ReferralsPage'; import ChallengesPage from '@/pages/volunteer/ChallengesPage'; import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage'; @@ -142,6 +144,8 @@ import MeetingAgendaPage from '@/pages/MeetingAgendaPage'; import ActionItemsPage from '@/pages/ActionItemsPage'; import SchedulingPollPage from '@/pages/public/SchedulingPollPage'; import PollsListPage from '@/pages/public/PollsListPage'; +import StrawPollPage from '@/pages/public/StrawPollPage'; +import StrawPollsListPage from '@/pages/public/StrawPollsListPage'; import JitsiAuthPage from '@/pages/JitsiAuthPage'; import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage'; import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage'; @@ -276,6 +280,14 @@ export default function App() { } /> + {/* Straw polls — feature-gated */} + }> + } /> + + }> + } /> + + {/* Public ticketed event pages — feature-gated */} }> } /> @@ -562,6 +574,14 @@ export default function App() { } /> + + + + } + /> , label: badges?.pendingResponses ? Responses : 'Responses' }, { key: '/app/influence/effectiveness', icon: , label: 'Effectiveness' }, { key: '/app/influence/stories', icon: , label: 'Impact Stories' }, + ...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: , label: 'Straw Polls' }] : []), ], }); } @@ -712,6 +714,7 @@ export default function AppLayout() { diff --git a/admin/src/components/FeatureGate.tsx b/admin/src/components/FeatureGate.tsx index 4364cdbe..3c76118f 100644 --- a/admin/src/components/FeatureGate.tsx +++ b/admin/src/components/FeatureGate.tsx @@ -22,10 +22,11 @@ const FEATURE_LABELS: Record = { enableMeetingPlanner: 'Meeting Planner', enableTicketedEvents: 'Ticketed Events', enableSocialCalendar: 'Social Calendar', + enablePolls: 'Straw Polls', }; interface FeatureGateProps { - feature: keyof Pick; + feature: keyof Pick; children: ReactNode; } diff --git a/admin/src/components/GrapesJSEditor.tsx b/admin/src/components/GrapesJSEditor.tsx index 7e16a16c..186acdfe 100644 --- a/admin/src/components/GrapesJSEditor.tsx +++ b/admin/src/components/GrapesJSEditor.tsx @@ -571,6 +571,40 @@ function generateBlockHtml(type: string, defaults: Record): str `; } + case 'straw-poll-inline': { + const pollSlug = (defaults.pollSlug as string) || ''; + return ` +
+
+
+ + + +

Straw Poll (Inline)

+

${pollSlug || 'Set poll slug in block properties'}

+

Inline voting widget renders on published page

+
+
+
`; + } + case 'straw-poll-card': { + const pollSlug = (defaults.pollSlug as string) || ''; + return ` +
+
+
+

Straw Poll (Card Link)

+

${pollSlug || 'Set poll slug in block properties'}

+

Preview card with vote link renders on published page

+
+
+
`; + } default: return `

Custom block: ${type}

`; } diff --git a/admin/src/components/people/UserAccountStatusPanel.tsx b/admin/src/components/people/UserAccountStatusPanel.tsx index 7de427f4..a856f66f 100644 --- a/admin/src/components/people/UserAccountStatusPanel.tsx +++ b/admin/src/components/people/UserAccountStatusPanel.tsx @@ -16,6 +16,7 @@ const roleColors: Record = { PAYMENTS_ADMIN: 'green', EVENTS_ADMIN: 'cyan', SOCIAL_ADMIN: 'magenta', + POLLS_ADMIN: 'geekblue', USER: 'blue', TEMP: 'default', }; diff --git a/admin/src/components/polls/PollResults.tsx b/admin/src/components/polls/PollResults.tsx new file mode 100644 index 00000000..464017d5 --- /dev/null +++ b/admin/src/components/polls/PollResults.tsx @@ -0,0 +1,51 @@ +import { Progress, Space, Typography } from 'antd'; +import type { StrawPollOption } from '@/types/api'; + +const { Text } = Typography; + +const YES_NO_COLORS: Record = { + Yes: '#52c41a', + No: '#ff4d4f', + Abstain: '#8c8c8c', +}; + +interface PollResultsProps { + options: StrawPollOption[]; + totalVotes: number; + type: 'SINGLE_CHOICE' | 'YES_NO_ABSTAIN'; +} + +const COLORS = ['#1890ff', '#52c41a', '#faad14', '#ff4d4f', '#722ed1', '#13c2c2', '#eb2f96', '#fa8c16', '#a0d911', '#2f54eb']; + +export default function PollResults({ options, totalVotes, type }: PollResultsProps) { + return ( +
+ {options.map((opt, i) => { + const count = opt.voteCount ?? opt._count?.votes ?? 0; + const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0; + const color = type === 'YES_NO_ABSTAIN' + ? YES_NO_COLORS[opt.label] || COLORS[i % COLORS.length] + : COLORS[i % COLORS.length]; + + return ( +
+ + {opt.label} + {count} vote{count !== 1 ? 's' : ''} ({pct}%) + + +
+ ); + })} + + Total: {totalVotes} vote{totalVotes !== 1 ? 's' : ''} + +
+ ); +} diff --git a/admin/src/pages/SettingsPage.tsx b/admin/src/pages/SettingsPage.tsx index f5f01872..9a4725c4 100644 --- a/admin/src/pages/SettingsPage.tsx +++ b/admin/src/pages/SettingsPage.tsx @@ -468,6 +468,9 @@ export default function SettingsPage() { + + + diff --git a/admin/src/pages/UsersPage.tsx b/admin/src/pages/UsersPage.tsx index 3429b1e3..c0d715ed 100644 --- a/admin/src/pages/UsersPage.tsx +++ b/admin/src/pages/UsersPage.tsx @@ -81,6 +81,7 @@ const roleColors: Record = { PAYMENTS_ADMIN: 'green', EVENTS_ADMIN: 'cyan', SOCIAL_ADMIN: 'magenta', + POLLS_ADMIN: 'geekblue', USER: 'blue', TEMP: 'default', }; diff --git a/admin/src/pages/influence/StrawPollsPage.tsx b/admin/src/pages/influence/StrawPollsPage.tsx new file mode 100644 index 00000000..841f7909 --- /dev/null +++ b/admin/src/pages/influence/StrawPollsPage.tsx @@ -0,0 +1,412 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + Table, Button, Space, Tag, Input, Select, Drawer, Form, Switch, Grid, + DatePicker, InputNumber, Radio, Typography, Popconfirm, Divider, + Descriptions, List, Card, Tooltip, App, +} from 'antd'; +import { + PlusOutlined, ReloadOutlined, CopyOutlined, PlayCircleOutlined, + PauseCircleOutlined, UndoOutlined, InboxOutlined, DeleteOutlined, + LinkOutlined, MinusCircleOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { api } from '@/lib/api'; +import PollResults from '@/components/polls/PollResults'; +import type { + StrawPoll, StrawPollType, StrawPollStatus, StrawPollIdentityMode, +} from '@/types/api'; +import { + STRAW_POLL_STATUS_COLORS, STRAW_POLL_STATUS_LABELS, + STRAW_POLL_TYPE_LABELS, STRAW_POLL_IDENTITY_LABELS, + STRAW_POLL_VISIBILITY_LABELS, +} from '@/types/api'; + +const { Search } = Input; +const { Text, Title } = Typography; + +export default function StrawPollsPage() { + const { message: msg } = App.useApp(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + const [polls, setPolls] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [page, setPage] = useState(1); + const [search, setSearch] = useState(''); + const [statusFilter, setStatusFilter] = useState(); + + // Drawers + const [createOpen, setCreateOpen] = useState(false); + const [detailOpen, setDetailOpen] = useState(false); + const [selectedPoll, setSelectedPoll] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + + const [createForm] = Form.useForm(); + const [pollType, setPollType] = useState('SINGLE_CHOICE'); + + const fetchPolls = useCallback(async () => { + setLoading(true); + try { + const params: Record = { page, limit: 20 }; + if (search) params.search = search; + if (statusFilter) params.status = statusFilter; + const { data } = await api.get('/straw-polls', { params }); + setPolls(data.polls); + setTotal(data.total); + } catch { + msg.error('Failed to load polls'); + } finally { + setLoading(false); + } + }, [page, search, statusFilter]); + + useEffect(() => { fetchPolls(); }, [fetchPolls]); + + const fetchDetail = async (id: string) => { + setDetailLoading(true); + try { + const { data } = await api.get(`/straw-polls/${id}`); + setSelectedPoll(data); + setDetailOpen(true); + } catch { + msg.error('Failed to load poll details'); + } finally { + setDetailLoading(false); + } + }; + + const handleCreate = async (values: any) => { + try { + const payload = { + ...values, + closesAt: values.closesAt ? values.closesAt.toISOString() : undefined, + options: values.type === 'YES_NO_ABSTAIN' ? undefined : values.options, + }; + await api.post('/straw-polls', payload); + msg.success('Poll created'); + setCreateOpen(false); + createForm.resetFields(); + fetchPolls(); + } catch { + msg.error('Failed to create poll'); + } + }; + + const handleLifecycle = async (id: string, action: string) => { + try { + await api.post(`/straw-polls/${id}/${action}`); + msg.success(`Poll ${action}d`); + fetchPolls(); + if (selectedPoll?.id === id) fetchDetail(id); + } catch (err: any) { + msg.error(err.response?.data?.error || `Failed to ${action} poll`); + } + }; + + const handleDelete = async (id: string) => { + try { + await api.delete(`/straw-polls/${id}`); + msg.success('Poll deleted'); + fetchPolls(); + if (selectedPoll?.id === id) setDetailOpen(false); + } catch { + msg.error('Failed to delete poll'); + } + }; + + const copyLink = (slug: string) => { + const url = `${window.location.origin}/straw-poll/${slug}`; + navigator.clipboard.writeText(url); + msg.success('Link copied'); + }; + + const columns = [ + { + title: 'Title', + dataIndex: 'title', + key: 'title', + render: (title: string, record: StrawPoll) => ( + fetchDetail(record.id)}>{title} + ), + }, + { + title: 'Type', + dataIndex: 'type', + key: 'type', + width: 150, + render: (type: StrawPollType) => {STRAW_POLL_TYPE_LABELS[type]}, + }, + { + title: 'Status', + dataIndex: 'status', + key: 'status', + width: 100, + render: (status: StrawPollStatus) => ( + {STRAW_POLL_STATUS_LABELS[status]} + ), + }, + { + title: 'Identity', + dataIndex: 'identityMode', + key: 'identityMode', + width: 130, + render: (mode: StrawPollIdentityMode) => STRAW_POLL_IDENTITY_LABELS[mode], + }, + { + title: 'Votes', + key: 'votes', + width: 80, + render: (_: any, record: StrawPoll) => record._count?.votes ?? 0, + }, + { + title: 'Created', + dataIndex: 'createdAt', + key: 'createdAt', + width: 120, + render: (d: string) => dayjs(d).format('MMM D, YYYY'), + }, + { + title: 'Actions', + key: 'actions', + width: 200, + render: (_: any, record: StrawPoll) => ( + + + + )} + {record.status === 'ACTIVE' && ( + + )} + {record.status === 'CLOSED' && ( + + + + + )} + handleDelete(record.id)}> + + )} + + )} + + )} + + + + + ({ label: v, value: k }))} /> + + + + + + + + + + + + + + + {/* Detail Drawer */} + setDetailOpen(false)} + width={isMobile ? '100%' : 600} + loading={detailLoading} + mask={false} + rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }} + > + {selectedPoll && ( +
+ + + {STRAW_POLL_STATUS_LABELS[selectedPoll.status]} + + {STRAW_POLL_TYPE_LABELS[selectedPoll.type]} + {STRAW_POLL_IDENTITY_LABELS[selectedPoll.identityMode]} + {STRAW_POLL_VISIBILITY_LABELS[selectedPoll.resultVisibility]} + {dayjs(selectedPoll.createdAt).format('MMM D, YYYY h:mm A')} + {selectedPoll.closesAt ? dayjs(selectedPoll.closesAt).format('MMM D, YYYY h:mm A') : 'Manual'} + + + {selectedPoll.description && ( + + {selectedPoll.description} + + )} + + {/* Lifecycle Controls */} + + {selectedPoll.status === 'DRAFT' && ( + + )} + {selectedPoll.status === 'ACTIVE' && ( + + )} + {selectedPoll.status === 'CLOSED' && ( + <> + + + + )} + + + + {/* Results */} + Vote Results + {selectedPoll.options && ( + + )} + + {/* Voters */} + {selectedPoll.votes && selectedPoll.votes.length > 0 && ( + <> + Voters ({selectedPoll.votes.length}) + ( + api.delete(`/straw-polls/${selectedPoll.id}/votes/${vote.id}`).then(() => fetchDetail(selectedPoll.id))}> +
+ )} +
+ + ); +} diff --git a/admin/src/pages/public/StrawPollPage.tsx b/admin/src/pages/public/StrawPollPage.tsx new file mode 100644 index 00000000..e4aed0d6 --- /dev/null +++ b/admin/src/pages/public/StrawPollPage.tsx @@ -0,0 +1,293 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; +import { + Card, Typography, Tag, Radio, Button, Input, Space, Spin, Result, + Divider, List, Form, Grid, App, +} from 'antd'; +import { CheckCircleFilled, ShareAltOutlined } from '@ant-design/icons'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import PollResults from '@/components/polls/PollResults'; +import type { StrawPoll } from '@/types/api'; +import { STRAW_POLL_STATUS_LABELS, STRAW_POLL_TYPE_LABELS } from '@/types/api'; +import { useAuthStore } from '@/stores/auth.store'; + +const { Title, Text, Paragraph } = Typography; + +const apiBase = '/api'; + +export default function StrawPollPage() { + const { slug } = useParams<{ slug: string }>(); + const { message: msg } = App.useApp(); + const { user, accessToken } = useAuthStore(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + + const [poll, setPoll] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedOption, setSelectedOption] = useState(''); + const [voterName, setVoterName] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [hasVoted, setHasVoted] = useState(false); + const [commentForm] = Form.useForm(); + + const sseRef = useRef(null); + + const storedToken = localStorage.getItem(`straw_poll_voter_token_${slug}`); + + const fetchPoll = useCallback(async () => { + if (!slug) return; + try { + const params: Record = {}; + if (storedToken) params.voterToken = storedToken; + const headers: Record = {}; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + const { data } = await axios.get(`${apiBase}/straw-polls/public/${slug}`, { params, headers }); + setPoll(data); + setHasVoted(!!data.hasVoted); + } catch { + setPoll(null); + } finally { + setLoading(false); + } + }, [slug, storedToken, accessToken]); + + useEffect(() => { fetchPoll(); }, [fetchPoll]); + + // SSE for live results + useEffect(() => { + if (!slug || !poll || poll.resultVisibility !== 'LIVE') return; + const es = new EventSource(`${apiBase}/straw-polls/public/${slug}/live`); + sseRef.current = es; + + es.addEventListener('vote_update', (e) => { + try { + const data = JSON.parse(e.data); + setPoll(prev => { + if (!prev || !prev.options) return prev; + const updated = prev.options.map(opt => { + const count = data.optionCounts?.find((c: any) => c.optionId === opt.id); + return count ? { ...opt, voteCount: count.count } : opt; + }); + return { ...prev, options: updated, totalVotes: data.totalVotes }; + }); + } catch {} + }); + + es.addEventListener('poll_closed', () => { + setPoll(prev => prev ? { ...prev, status: 'CLOSED' } : prev); + msg.info('This poll has been closed'); + }); + + es.addEventListener('comment_added', () => { + fetchPoll(); // Refresh to get new comment + }); + + return () => { es.close(); }; + }, [slug, poll?.resultVisibility]); + + const handleVote = async () => { + if (!selectedOption || !slug) return; + setSubmitting(true); + try { + const body: any = { optionId: selectedOption }; + if (voterName) body.voterName = voterName; + if (storedToken) body.voterToken = storedToken; + + const headers: Record = { 'Content-Type': 'application/json' }; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + + const { data } = await axios.post(`${apiBase}/straw-polls/public/${slug}/vote`, body, { headers }); + + if (data.voterToken) { + localStorage.setItem(`straw_poll_voter_token_${slug}`, data.voterToken); + } + + setHasVoted(true); + msg.success('Vote submitted!'); + fetchPoll(); + } catch (err: any) { + msg.error(err.response?.data?.error || 'Failed to vote'); + } finally { + setSubmitting(false); + } + }; + + const handleComment = async (values: any) => { + if (!slug) return; + try { + const headers: Record = { 'Content-Type': 'application/json' }; + if (accessToken) headers.Authorization = `Bearer ${accessToken}`; + await axios.post(`${apiBase}/straw-polls/public/${slug}/comment`, values, { headers }); + commentForm.resetFields(); + fetchPoll(); + } catch { + msg.error('Failed to post comment'); + } + }; + + if (loading) return ; + if (!poll) return ; + if (poll.requiresAuth && !user) return ; + + const showVoteForm = poll.status === 'ACTIVE' && !hasVoted; + const showResults = poll.showResults || hasVoted; + + return ( +
+ {/* Hero */} +
+ + {STRAW_POLL_TYPE_LABELS[poll.type]} + + {STRAW_POLL_STATUS_LABELS[poll.status]} + + + {poll.title} + {poll.description && {poll.description}} + {poll.createdBy && by {poll.createdBy.name}} + {poll.closesAt && poll.status === 'ACTIVE' && ( +
+ Closes {dayjs(poll.closesAt).format('MMM D, YYYY h:mm A')} +
+ )} +
+ + {/* Vote Form */} + {showVoteForm && ( + + Cast Your Vote + + {poll.type === 'YES_NO_ABSTAIN' ? ( + + {poll.options?.map(opt => ( + + ))} + + ) : ( + setSelectedOption(e.target.value)} + style={{ width: '100%', marginBottom: 16 }} + > + + {poll.options?.map(opt => ( + + {opt.label} + + ))} + + + )} + + {(poll.identityMode === 'ANONYMOUS' || poll.identityMode === 'MIXED') && !user && ( + setVoterName(e.target.value)} + style={{ marginBottom: 16, maxWidth: 300 }} + /> + )} + + + + )} + + {/* Already Voted */} + {hasVoted && poll.status === 'ACTIVE' && ( + + + You've voted! + Your vote has been recorded. + + )} + + {/* Results */} + {showResults && poll.options && ( + + Results + + + )} + + {!showResults && poll.resultVisibility !== 'LIVE' && poll.resultVisibility !== 'PUBLIC_ALWAYS' && ( + + + Results will be visible {poll.resultVisibility === 'AFTER_VOTE' ? 'after you vote' : poll.resultVisibility === 'AFTER_CLOSE' ? 'when the poll closes' : ''} + + + )} + + {/* Share */} +
+ +
+ + {/* Comments */} + {poll.allowComments && ( + <> + Comments +
+ + + + + + + +
+ + {poll.comments && poll.comments.length > 0 ? ( + ( + + + {dayjs(comment.createdAt).format('MMM D, h:mm A')} + + )} + /> + ) : ( + No comments yet. + )} + + )} +
+ ); +} diff --git a/admin/src/pages/public/StrawPollsListPage.tsx b/admin/src/pages/public/StrawPollsListPage.tsx new file mode 100644 index 00000000..8647b855 --- /dev/null +++ b/admin/src/pages/public/StrawPollsListPage.tsx @@ -0,0 +1,63 @@ +import { useState, useEffect } from 'react'; +import { Card, Row, Col, Tag, Typography, Spin, Empty, Grid } from 'antd'; +import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import type { StrawPoll } from '@/types/api'; +import { STRAW_POLL_TYPE_LABELS } from '@/types/api'; + +dayjs.extend(relativeTime); + +const { Title, Text, Paragraph } = Typography; + +const apiBase = '/api'; + +export default function StrawPollsListPage() { + const navigate = useNavigate(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + const [polls, setPolls] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + axios.get(`${apiBase}/straw-polls/public`, { params: { limit: 50 } }) + .then(res => setPolls(res.data.polls)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) return ; + if (polls.length === 0) return ; + + return ( +
+ Straw Polls + + {polls.map(poll => ( + + navigate(`/straw-poll/${poll.slug}`)} + style={{ height: '100%' }} + > + {STRAW_POLL_TYPE_LABELS[poll.type]} + {poll.title} + {poll.description && ( + + {poll.description} + + )} +
+ {poll._count?.votes ?? 0} votes + {poll.closesAt && ( + Closes {dayjs(poll.closesAt).fromNow()} + )} +
+
+ + ))} +
+
+ ); +} diff --git a/admin/src/types/api.ts b/admin/src/types/api.ts index 84db201e..d8b7274a 100644 --- a/admin/src/types/api.ts +++ b/admin/src/types/api.ts @@ -13,7 +13,7 @@ export interface AppOutletContext { setPageHeader: (config: PageHeaderConfig | null) => void; } -export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'USER' | 'TEMP'; +export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'POLLS_ADMIN' | 'USER' | 'TEMP'; export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL'; @@ -101,7 +101,7 @@ export interface UsersListParams { status?: UserStatus; } -export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN']; +export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN', 'POLLS_ADMIN']; // Module-specific role groups export const INFLUENCE_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN']; @@ -114,6 +114,7 @@ export const EVENTS_ROLES: UserRole[] = ['SUPER_ADMIN', 'EVENTS_ADMIN']; export const SOCIAL_ROLES: UserRole[] = ['SUPER_ADMIN', 'SOCIAL_ADMIN']; export const SYSTEM_ROLES: UserRole[] = ['SUPER_ADMIN']; export const SCHEDULING_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN', 'EVENTS_ADMIN']; +export const POLLS_ROLES: UserRole[] = ['SUPER_ADMIN', 'POLLS_ADMIN', 'INFLUENCE_ADMIN']; // --- User Provisioning --- @@ -1169,6 +1170,7 @@ export interface SiteSettings { enableMeetingPlanner: boolean; enableTicketedEvents: boolean; enableSocialCalendar: boolean; + enablePolls: boolean; enableDocsCollaboration: boolean; requireEventApproval: boolean; autoSyncPeopleToMap: boolean; @@ -3356,3 +3358,98 @@ export interface CalendarExportToken { createdAt: string; } +// ===== Straw Polls ===== + +export type StrawPollType = 'SINGLE_CHOICE' | 'YES_NO_ABSTAIN'; +export type StrawPollStatus = 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED'; +export type StrawPollIdentityMode = 'ANONYMOUS' | 'TOKEN_GATED' | 'AUTHENTICATED' | 'MIXED'; +export type StrawPollResultVisibility = 'LIVE' | 'AFTER_VOTE' | 'AFTER_CLOSE' | 'CREATOR_ONLY' | 'PUBLIC_ALWAYS'; + +export const STRAW_POLL_STATUS_COLORS: Record = { + DRAFT: 'default', + ACTIVE: 'green', + CLOSED: 'orange', + ARCHIVED: 'red', +}; + +export const STRAW_POLL_STATUS_LABELS: Record = { + DRAFT: 'Draft', + ACTIVE: 'Active', + CLOSED: 'Closed', + ARCHIVED: 'Archived', +}; + +export const STRAW_POLL_TYPE_LABELS: Record = { + SINGLE_CHOICE: 'Single Choice', + YES_NO_ABSTAIN: 'Yes / No / Abstain', +}; + +export const STRAW_POLL_IDENTITY_LABELS: Record = { + ANONYMOUS: 'Anonymous', + TOKEN_GATED: 'Token-Gated', + AUTHENTICATED: 'Login Required', + MIXED: 'Mixed', +}; + +export const STRAW_POLL_VISIBILITY_LABELS: Record = { + LIVE: 'Live Results', + AFTER_VOTE: 'After Voting', + AFTER_CLOSE: 'After Close', + CREATOR_ONLY: 'Creator Only', + PUBLIC_ALWAYS: 'Public Always', +}; + +export interface StrawPollOption { + id: string; + label: string; + sortOrder: number; + voteCount?: number; + _count?: { votes: number }; +} + +export interface StrawPollVote { + id: string; + optionId: string; + userId: string | null; + voterName: string | null; + createdAt: string; + user?: { id: string; name: string | null; email: string }; +} + +export interface StrawPollComment { + id: string; + authorName: string; + content: string; + userId: string | null; + createdAt: string; +} + +export interface StrawPoll { + id: string; + slug: string; + title: string; + description: string | null; + type: StrawPollType; + status: StrawPollStatus; + identityMode: StrawPollIdentityMode; + resultVisibility: StrawPollResultVisibility; + allowComments: boolean; + closesAt: string | null; + closeThreshold: number | null; + autoCloseJobId: string | null; + isPrivate: boolean; + createdByUserId: string; + createdBy?: { id: string; name: string | null; email: string }; + createdAt: string; + updatedAt: string; + options?: StrawPollOption[]; + votes?: StrawPollVote[]; + comments?: StrawPollComment[]; + _count?: { votes: number; comments: number; options: number }; + // Public view extras + totalVotes?: number; + showResults?: boolean; + hasVoted?: boolean; + requiresAuth?: boolean; +} + diff --git a/api/prisma/migrations/20260330100000_add_straw_polls/migration.sql b/api/prisma/migrations/20260330100000_add_straw_polls/migration.sql new file mode 100644 index 00000000..4901b1da --- /dev/null +++ b/api/prisma/migrations/20260330100000_add_straw_polls/migration.sql @@ -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; + diff --git a/api/prisma/seed.ts b/api/prisma/seed.ts index dd406d50..0de35c9a 100644 --- a/api/prisma/seed.ts +++ b/api/prisma/seed.ts @@ -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) { diff --git a/api/src/modules/polls/polls-public.routes.ts b/api/src/modules/polls/polls-public.routes.ts new file mode 100644 index 00000000..88283fda --- /dev/null +++ b/api/src/modules/polls/polls-public.routes.ts @@ -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 }; diff --git a/api/src/modules/polls/polls-sse.service.ts b/api/src/modules/polls/polls-sse.service.ts new file mode 100644 index 00000000..f0cf28b8 --- /dev/null +++ b/api/src/modules/polls/polls-sse.service.ts @@ -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(); // 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(); diff --git a/api/src/modules/polls/polls-widget.routes.ts b/api/src/modules/polls/polls-widget.routes.ts new file mode 100644 index 00000000..47d7dc2b --- /dev/null +++ b/api/src/modules/polls/polls-widget.routes.ts @@ -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 }; diff --git a/api/src/modules/polls/polls.rate-limits.ts b/api/src/modules/polls/polls.rate-limits.ts new file mode 100644 index 00000000..a070ea64 --- /dev/null +++ b/api/src/modules/polls/polls.rate-limits.ts @@ -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, + 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, + prefix: 'rl:straw-poll-comment:', + }), + message: { + error: { + message: 'Too many comments, please try again later', + code: 'STRAW_POLL_COMMENT_RATE_LIMIT_EXCEEDED', + }, + }, +}); diff --git a/api/src/modules/polls/polls.routes.ts b/api/src/modules/polls/polls.routes.ts new file mode 100644 index 00000000..cb80270b --- /dev/null +++ b/api/src/modules/polls/polls.routes.ts @@ -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 }; diff --git a/api/src/modules/polls/polls.schemas.ts b/api/src/modules/polls/polls.schemas.ts new file mode 100644 index 00000000..bd0b8b26 --- /dev/null +++ b/api/src/modules/polls/polls.schemas.ts @@ -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; +export type UpdateStrawPollInput = z.infer; +export type SubmitStrawPollVoteInput = z.infer; +export type SubmitStrawPollCommentInput = z.infer; +export type ListStrawPollsInput = z.infer; +export type ChallengeVoteInput = z.infer; +export type GenerateLinksInput = z.infer; diff --git a/api/src/modules/polls/polls.service.ts b/api/src/modules/polls/polls.service.ts new file mode 100644 index 00000000..6b52fb01 --- /dev/null +++ b/api/src/modules/polls/polls.service.ts @@ -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 { + 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(); diff --git a/api/src/modules/settings/settings.schemas.ts b/api/src/modules/settings/settings.schemas.ts index 666bf333..e86fd4cc 100644 --- a/api/src/modules/settings/settings.schemas.ts +++ b/api/src/modules/settings/settings.schemas.ts @@ -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(), diff --git a/api/src/modules/social/feed.service.ts b/api/src/modules/social/feed.service.ts index e67e09b6..f03c4707 100644 --- a/api/src/modules/social/feed.service.ts +++ b/api/src/modules/social/feed.service.ts @@ -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 { + 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, + })); + }, }; diff --git a/api/src/modules/social/integration.routes.ts b/api/src/modules/social/integration.routes.ts index 4f102d7a..864558f3 100644 --- a/api/src/modules/social/integration.routes.ts +++ b/api/src/modules/social/integration.routes.ts @@ -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 { diff --git a/api/src/modules/social/integration.service.ts b/api/src/modules/social/integration.service.ts index bf2967ca..32d9675f 100644 --- a/api/src/modules/social/integration.service.ts +++ b/api/src/modules/social/integration.service.ts @@ -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> { const hidden = await prisma.privacySettings.findMany({ diff --git a/api/src/modules/social/notification.service.ts b/api/src/modules/social/notification.service.ts index 807b717b..c49afea2 100644 --- a/api/src/modules/social/notification.service.ts +++ b/api/src/modules/social/notification.service.ts @@ -25,6 +25,10 @@ const TYPE_TO_PREF: Record = { 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 = { diff --git a/api/src/services/poll-auto-close-queue.service.ts b/api/src/services/poll-auto-close-queue.service.ts new file mode 100644 index 00000000..46ea5522 --- /dev/null +++ b/api/src/services/poll-auto-close-queue.service.ts @@ -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) => { + 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 { + 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 { + 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(); diff --git a/api/src/utils/roles.ts b/api/src/utils/roles.ts index 3cbb6dc4..35306392 100644 --- a/api/src/utils/roles.ts +++ b/api/src/utils/roles.ts @@ -10,6 +10,7 @@ const ROLE_PRIORITY: Record = { 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 { diff --git a/mkdocs/docs/assets/js/straw-poll-widget.js b/mkdocs/docs/assets/js/straw-poll-widget.js new file mode 100644 index 00000000..a245d59d --- /dev/null +++ b/mkdocs/docs/assets/js/straw-poll-widget.js @@ -0,0 +1,226 @@ +/** + * Straw Poll Widget Hydration for MkDocs + * + * Supports two modes: + * .straw-poll-inline — Full voting UI embedded in docs page + * .straw-poll-card — Preview card linking to the full poll lander + * + * Uses the lightweight /api/straw-polls/widget/:slug endpoint (cached). + * Follows the scheduling-poll.js hydration pattern. + */ +(function () { + 'use strict'; + + function getApiUrl() { + if (window.PAYMENT_API_URL) return window.PAYMENT_API_URL; + if (window.API_URL) return window.API_URL; + var host = window.location.hostname; + if (host !== 'localhost' && host.indexOf('.') !== -1) { + var parts = host.split('.'); + var base = parts.slice(-2).join('.'); + return window.location.protocol + '//api.' + base; + } + return 'http://localhost:4000'; + } + + function getAppUrl() { + if (window.APP_URL) return window.APP_URL; + var host = window.location.hostname; + if (host !== 'localhost' && host.indexOf('.') !== -1) { + var parts = host.split('.'); + var base = parts.slice(-2).join('.'); + return window.location.protocol + '//app.' + base; + } + return 'http://localhost:3000'; + } + + var COLORS = ['#1890ff', '#52c41a', '#faad14', '#ff4d4f', '#722ed1', '#13c2c2', '#eb2f96']; + var YNA_COLORS = { Yes: '#52c41a', No: '#ff4d4f', Abstain: '#8c8c8c' }; + + function tokenKey(slug) { + return 'straw_poll_voter_token_' + slug; + } + + // ===== Card Mode ===== + function renderCard(block, poll, appUrl) { + var html = '
'; + html += '
'; + html += (poll.type === 'YES_NO_ABSTAIN' ? 'Yes / No / Abstain' : 'Single Choice') + ' Poll
'; + html += '

' + poll.title + '

'; + html += '
' + poll.totalVotes + ' vote' + (poll.totalVotes !== 1 ? 's' : '') + '
'; + if (poll.status === 'ACTIVE') { + html += ' 0; + + var html = '
'; + + // Title + html += '

' + poll.title + '

'; + html += '
'; + html += (poll.type === 'YES_NO_ABSTAIN' ? 'Yes / No / Abstain' : 'Single Choice'); + html += ' · ' + poll.totalVotes + ' vote' + (poll.totalVotes !== 1 ? 's' : '') + '
'; + + if (poll.status === 'ACTIVE' && !hasVoted) { + // Vote form + html += '
'; + poll.options.forEach(function (opt, i) { + var isYNA = poll.type === 'YES_NO_ABSTAIN'; + var color = isYNA ? (YNA_COLORS[opt.label] || COLORS[i]) : COLORS[i % COLORS.length]; + html += ''; + }); + html += ''; + html += ''; + html += '
'; + } + + // Results + if (showResults && (hasVoted || poll.status !== 'ACTIVE')) { + html += renderResultsHtml(poll); + } + + if (hasVoted && poll.status === 'ACTIVE') { + html += '
✓ You\'ve voted
'; + } + + html += '
'; + block.innerHTML = html; + + // Wire up vote form + if (poll.status === 'ACTIVE' && !hasVoted) { + wireVoteForm(block, poll, apiUrl, slug); + } + } + + function renderResultsHtml(poll) { + var html = '
'; + poll.options.forEach(function (opt, i) { + var pct = poll.totalVotes > 0 ? Math.round((opt.voteCount / poll.totalVotes) * 100) : 0; + var color = poll.type === 'YES_NO_ABSTAIN' ? (YNA_COLORS[opt.label] || COLORS[i]) : COLORS[i % COLORS.length]; + html += '
'; + html += '
'; + html += '' + opt.label + '' + opt.voteCount + ' (' + pct + '%)
'; + html += '
'; + html += '
'; + html += '
'; + }); + html += '
'; + return html; + } + + function wireVoteForm(block, poll, apiUrl, slug) { + var selectedId = null; + var buttons = block.querySelectorAll('.sp-opt-btn'); + var submitBtn = block.querySelector('#sp-submit-' + slug); + + buttons.forEach(function (btn) { + btn.addEventListener('click', function () { + selectedId = btn.getAttribute('data-option-id'); + buttons.forEach(function (b) { b.style.borderWidth = '2px'; b.style.fontWeight = 'normal'; }); + btn.style.borderWidth = '3px'; + btn.style.fontWeight = '600'; + submitBtn.disabled = false; + submitBtn.style.opacity = '1'; + }); + }); + + submitBtn.addEventListener('click', function () { + if (!selectedId) return; + submitBtn.disabled = true; + submitBtn.textContent = 'Submitting...'; + + var voterName = (block.querySelector('#sp-voter-name-' + slug) || {}).value || ''; + var body = { optionId: selectedId }; + if (voterName) body.voterName = voterName; + var storedToken = localStorage.getItem(tokenKey(slug)); + if (storedToken) body.voterToken = storedToken; + + fetch(apiUrl + '/api/straw-polls/public/' + encodeURIComponent(slug) + '/vote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + .then(function (res) { return res.json(); }) + .then(function (data) { + if (data.voterToken) localStorage.setItem(tokenKey(slug), data.voterToken); + // Re-fetch and re-render with results + return fetch(apiUrl + '/api/straw-polls/widget/' + encodeURIComponent(slug)).then(function (r) { return r.json(); }); + }) + .then(function (updated) { + renderInline(block, updated, apiUrl); + }) + .catch(function () { + submitBtn.textContent = 'Error — try again'; + submitBtn.disabled = false; + submitBtn.style.opacity = '1'; + }); + }); + } + + // ===== Hydration ===== + function hydrateBlocks() { + var apiUrl = getApiUrl(); + var appUrl = getAppUrl(); + + // Inline embeds + document.querySelectorAll('.straw-poll-inline').forEach(function (block) { + if (block.getAttribute('data-hydrated') === 'true') return; + var slug = block.getAttribute('data-poll-slug'); + if (!slug) return; + block.setAttribute('data-hydrated', 'true'); + block.innerHTML = '
Loading poll...
'; + + fetch(apiUrl + '/api/straw-polls/widget/' + encodeURIComponent(slug)) + .then(function (res) { if (!res.ok) throw new Error(); return res.json(); }) + .then(function (poll) { renderInline(block, poll, apiUrl); }) + .catch(function () { + block.innerHTML = '
Poll unavailable
'; + }); + }); + + // Card links + document.querySelectorAll('.straw-poll-card').forEach(function (block) { + if (block.getAttribute('data-hydrated') === 'true') return; + var slug = block.getAttribute('data-poll-slug'); + if (!slug) return; + block.setAttribute('data-hydrated', 'true'); + block.innerHTML = '
Loading...
'; + + fetch(apiUrl + '/api/straw-polls/widget/' + encodeURIComponent(slug)) + .then(function (res) { if (!res.ok) throw new Error(); return res.json(); }) + .then(function (poll) { renderCard(block, poll, appUrl); }) + .catch(function () { + block.innerHTML = '
Poll unavailable
'; + }); + }); + } + + // Initial hydration + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', hydrateBlocks); + } else { + hydrateBlocks(); + } + + // Re-hydrate on MkDocs SPA navigation + if (typeof document$ !== 'undefined') { + document$.subscribe(function () { setTimeout(hydrateBlocks, 100); }); + } +})(); diff --git a/mkdocs/mkdocs.yml b/mkdocs/mkdocs.yml index 7e7d68f0..5946a946 100644 --- a/mkdocs/mkdocs.yml +++ b/mkdocs/mkdocs.yml @@ -9,7 +9,7 @@ use_directory_urls: true # Repository repo_url: https://gitea.bnkops.com/admin/changemaker.lite repo_name: changemaker.lite -edit_uri: src/branch/v2/mkdocs/docs +edit_uri: src/branch/main/mkdocs/docs # Theme theme: @@ -95,6 +95,7 @@ extra_javascript: - assets/js/gancio-events.js - assets/js/payment-widgets.js - assets/js/scheduling-poll.js + - assets/js/straw-poll-widget.js - javascripts/ad-widgets.js - javascripts/docs-comments.js