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

View File

@ -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() {
<Route index element={<SchedulingPollPage />} />
</Route>
{/* Straw polls — feature-gated */}
<Route path="/straw-polls" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
<Route index element={<StrawPollsListPage />} />
</Route>
<Route path="/straw-poll/:slug" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
<Route index element={<StrawPollPage />} />
</Route>
{/* Public ticketed event pages — feature-gated */}
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<TicketedEventDetailPage />} />
@ -562,6 +574,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="influence/straw-polls"
element={
<ProtectedRoute requiredRoles={POLLS_ROLES}>
<StrawPollsPage />
</ProtectedRoute>
}
/>
<Route
path="listmonk"
element={

View File

@ -71,6 +71,7 @@ import {
MEDIA_ROLES,
PAYMENTS_ROLES,
SOCIAL_ROLES,
POLLS_ROLES,
} from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api';
@ -187,6 +188,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []),
],
});
}
@ -712,6 +714,7 @@ export default function AppLayout() {
</Dropdown>
</Header>
<Content
id="app-content-area"
style={{
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
@ -719,6 +722,7 @@ export default function AppLayout() {
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
minHeight: 280,
overflow: fullBleed ? 'hidden' : undefined,
position: 'relative',
}}
>
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />

View File

@ -22,10 +22,11 @@ const FEATURE_LABELS: Record<string, string> = {
enableMeetingPlanner: 'Meeting Planner',
enableTicketedEvents: 'Ticketed Events',
enableSocialCalendar: 'Social Calendar',
enablePolls: 'Straw Polls',
};
interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar'>;
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePolls'>;
children: ReactNode;
}

View File

@ -571,6 +571,40 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
</div>
</section>`;
}
case 'straw-poll-inline': {
const pollSlug = (defaults.pollSlug as string) || '';
return `
<section style="padding: 60px 40px;">
<div class="straw-poll-inline"
data-poll-slug="${pollSlug}"
data-show-results="true"
style="max-width: 500px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); border-radius: 12px; padding: 32px; text-align: center; color: #fff;">
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M160 960h128V480H160v480zm256 0h128V320H416v640zm256 0h128V160H672v800z"/>
</svg>
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">Straw Poll (Inline)</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Inline voting widget renders on published page</p>
</div>
</div>
</section>`;
}
case 'straw-poll-card': {
const pollSlug = (defaults.pollSlug as string) || '';
return `
<section style="padding: 40px;">
<div class="straw-poll-card"
data-poll-slug="${pollSlug}"
style="max-width: 400px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #722ed1 0%, #531dab 100%); border-radius: 12px; padding: 24px; text-align: center; color: #fff;">
<p style="margin: 0; font-size: 1rem; font-weight: 600;">Straw Poll (Card Link)</p>
<p style="margin: 8px 0 0; font-size: 0.85rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
<p style="margin: 8px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Preview card with vote link renders on published page</p>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}

View File

@ -16,6 +16,7 @@ const roleColors: Record<UserRole, string> = {
PAYMENTS_ADMIN: 'green',
EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta',
POLLS_ADMIN: 'geekblue',
USER: 'blue',
TEMP: 'default',
};

View File

@ -0,0 +1,51 @@
import { Progress, Space, Typography } from 'antd';
import type { StrawPollOption } from '@/types/api';
const { Text } = Typography;
const YES_NO_COLORS: Record<string, string> = {
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 (
<div>
{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 (
<div key={opt.id} style={{ marginBottom: 12 }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>{opt.label}</Text>
<Text type="secondary">{count} vote{count !== 1 ? 's' : ''} ({pct}%)</Text>
</Space>
<Progress
percent={pct}
showInfo={false}
strokeColor={color}
size="small"
style={{ marginTop: 4 }}
/>
</div>
);
})}
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
Total: {totalVotes} vote{totalVotes !== 1 ? 's' : ''}
</Text>
</div>
);
}

View File

@ -468,6 +468,9 @@ export default function SettingsPage() {
<Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Straw Polls" name="enablePolls" valuePropName="checked" extra="Quick opinion polls with public landers and MkDocs widgets" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Map & Canvassing" name="enableMap" valuePropName="checked" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>

View File

@ -81,6 +81,7 @@ const roleColors: Record<UserRole, string> = {
PAYMENTS_ADMIN: 'green',
EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta',
POLLS_ADMIN: 'geekblue',
USER: 'blue',
TEMP: 'default',
};

View File

@ -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<StrawPoll[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StrawPollStatus | undefined>();
// Drawers
const [createOpen, setCreateOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedPoll, setSelectedPoll] = useState<StrawPoll | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [createForm] = Form.useForm();
const [pollType, setPollType] = useState<StrawPollType>('SINGLE_CHOICE');
const fetchPolls = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, any> = { 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) => (
<a onClick={() => fetchDetail(record.id)}>{title}</a>
),
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
width: 150,
render: (type: StrawPollType) => <Tag>{STRAW_POLL_TYPE_LABELS[type]}</Tag>,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: StrawPollStatus) => (
<Tag color={STRAW_POLL_STATUS_COLORS[status]}>{STRAW_POLL_STATUS_LABELS[status]}</Tag>
),
},
{
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) => (
<Space size="small">
<Tooltip title="Copy public link">
<Button size="small" icon={<CopyOutlined />} onClick={() => copyLink(record.slug)} />
</Tooltip>
{record.status === 'DRAFT' && (
<Button size="small" type="primary" icon={<PlayCircleOutlined />} onClick={() => handleLifecycle(record.id, 'activate')}>
Activate
</Button>
)}
{record.status === 'ACTIVE' && (
<Button size="small" icon={<PauseCircleOutlined />} onClick={() => handleLifecycle(record.id, 'close')}>
Close
</Button>
)}
{record.status === 'CLOSED' && (
<Space size="small">
<Button size="small" icon={<UndoOutlined />} onClick={() => handleLifecycle(record.id, 'reopen')}>Reopen</Button>
<Button size="small" icon={<InboxOutlined />} onClick={() => handleLifecycle(record.id, 'archive')}>Archive</Button>
</Space>
)}
<Popconfirm title="Delete this poll?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const drawerOpen = createOpen || detailOpen;
const drawerWidth = isMobile ? 0 : (detailOpen ? 600 : 520);
return (
<>
<div style={{ padding: 24, marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>Straw Polls</Title>
<Space>
<Search placeholder="Search polls..." allowClear onSearch={setSearch} style={{ width: 250 }} />
<Select
placeholder="Status"
allowClear
style={{ width: 130 }}
onChange={setStatusFilter}
options={Object.entries(STRAW_POLL_STATUS_LABELS).map(([k, v]) => ({ label: v, value: k }))}
/>
<Button icon={<ReloadOutlined />} onClick={fetchPolls} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setPollType('SINGLE_CHOICE'); setCreateOpen(true); }}>
New Poll
</Button>
</Space>
</Space>
<Table
dataSource={polls}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ current: page, total, pageSize: 20, onChange: setPage, showSizeChanger: false }}
/>
</div>
{/* Create Drawer */}
<Drawer
title="Create Straw Poll"
open={createOpen}
onClose={() => setCreateOpen(false)}
width={isMobile ? '100%' : 520}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={<Button type="primary" onClick={() => createForm.submit()}>Create</Button>}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ type: 'SINGLE_CHOICE', identityMode: 'ANONYMOUS', resultVisibility: 'LIVE', allowComments: true, isPrivate: false }}>
<Form.Item name="title" label="Title" rules={[{ required: true, max: 200 }]}>
<Input placeholder="e.g., Should we add bike lanes on Main Street?" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} maxLength={2000} />
</Form.Item>
<Form.Item name="type" label="Poll Type" rules={[{ required: true }]}>
<Radio.Group onChange={(e) => setPollType(e.target.value)}>
<Radio.Button value="SINGLE_CHOICE">Single Choice</Radio.Button>
<Radio.Button value="YES_NO_ABSTAIN">Yes / No / Abstain</Radio.Button>
</Radio.Group>
</Form.Item>
{pollType === 'SINGLE_CHOICE' && (
<Form.List name="options" initialValue={[{ label: '' }, { label: '' }]}>
{(fields, { add, remove }) => (
<div>
<Text strong>Options</Text>
{fields.map((field, index) => (
<Space key={field.key} style={{ display: 'flex', marginBottom: 8, marginTop: index === 0 ? 8 : 0 }} align="baseline">
<Form.Item {...field} name={[field.name, 'label']} rules={[{ required: true, message: 'Option required' }]} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={`Option ${index + 1}`} />
</Form.Item>
{fields.length > 2 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ color: '#ff4d4f' }} />
)}
</Space>
))}
{fields.length < 20 && (
<Button type="dashed" onClick={() => add({ label: '' })} icon={<PlusOutlined />} style={{ width: '100%', marginTop: 8 }}>
Add Option
</Button>
)}
</div>
)}
</Form.List>
)}
<Divider />
<Form.Item name="identityMode" label="Identity Mode">
<Select options={Object.entries(STRAW_POLL_IDENTITY_LABELS).map(([k, v]) => ({ label: v, value: k }))} />
</Form.Item>
<Form.Item name="resultVisibility" label="Result Visibility">
<Select options={Object.entries(STRAW_POLL_VISIBILITY_LABELS).map(([k, v]) => ({ label: v, value: k }))} />
</Form.Item>
<Form.Item name="closesAt" label="Auto-close Date">
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="closeThreshold" label="Auto-close Vote Threshold">
<InputNumber min={1} max={100000} style={{ width: '100%' }} placeholder="Close after N votes" />
</Form.Item>
<Space>
<Form.Item name="allowComments" valuePropName="checked"><Switch checkedChildren="Comments" unCheckedChildren="No Comments" /></Form.Item>
<Form.Item name="isPrivate" valuePropName="checked"><Switch checkedChildren="Private" unCheckedChildren="Public" /></Form.Item>
</Space>
</Form>
</Drawer>
{/* Detail Drawer */}
<Drawer
title={selectedPoll?.title || 'Poll Detail'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={isMobile ? '100%' : 600}
loading={detailLoading}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
>
{selectedPoll && (
<div>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Status">
<Tag color={STRAW_POLL_STATUS_COLORS[selectedPoll.status]}>{STRAW_POLL_STATUS_LABELS[selectedPoll.status]}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Type">{STRAW_POLL_TYPE_LABELS[selectedPoll.type]}</Descriptions.Item>
<Descriptions.Item label="Identity">{STRAW_POLL_IDENTITY_LABELS[selectedPoll.identityMode]}</Descriptions.Item>
<Descriptions.Item label="Visibility">{STRAW_POLL_VISIBILITY_LABELS[selectedPoll.resultVisibility]}</Descriptions.Item>
<Descriptions.Item label="Created">{dayjs(selectedPoll.createdAt).format('MMM D, YYYY h:mm A')}</Descriptions.Item>
<Descriptions.Item label="Closes">{selectedPoll.closesAt ? dayjs(selectedPoll.closesAt).format('MMM D, YYYY h:mm A') : 'Manual'}</Descriptions.Item>
</Descriptions>
{selectedPoll.description && (
<Card size="small" style={{ marginBottom: 16 }}>
<Text>{selectedPoll.description}</Text>
</Card>
)}
{/* Lifecycle Controls */}
<Space style={{ marginBottom: 16 }}>
{selectedPoll.status === 'DRAFT' && (
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => handleLifecycle(selectedPoll.id, 'activate')}>Activate</Button>
)}
{selectedPoll.status === 'ACTIVE' && (
<Button icon={<PauseCircleOutlined />} onClick={() => handleLifecycle(selectedPoll.id, 'close')}>Close</Button>
)}
{selectedPoll.status === 'CLOSED' && (
<>
<Button icon={<UndoOutlined />} onClick={() => handleLifecycle(selectedPoll.id, 'reopen')}>Reopen</Button>
<Button icon={<InboxOutlined />} onClick={() => handleLifecycle(selectedPoll.id, 'archive')}>Archive</Button>
</>
)}
<Button icon={<LinkOutlined />} onClick={() => copyLink(selectedPoll.slug)}>Copy Link</Button>
</Space>
{/* Results */}
<Divider>Vote Results</Divider>
{selectedPoll.options && (
<PollResults
options={selectedPoll.options}
totalVotes={selectedPoll._count?.votes ?? 0}
type={selectedPoll.type}
/>
)}
{/* Voters */}
{selectedPoll.votes && selectedPoll.votes.length > 0 && (
<>
<Divider>Voters ({selectedPoll.votes.length})</Divider>
<List
size="small"
dataSource={selectedPoll.votes}
renderItem={(vote: any) => (
<List.Item
extra={
<Popconfirm title="Remove this vote?" onConfirm={() => api.delete(`/straw-polls/${selectedPoll.id}/votes/${vote.id}`).then(() => fetchDetail(selectedPoll.id))}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
}
>
<List.Item.Meta
title={vote.user?.name || vote.voterName || 'Anonymous'}
description={`Voted for: ${selectedPoll.options?.find(o => o.id === vote.optionId)?.label || 'Unknown'}${dayjs(vote.createdAt).format('MMM D, h:mm A')}`}
/>
</List.Item>
)}
/>
</>
)}
{/* Comments */}
{selectedPoll.comments && selectedPoll.comments.length > 0 && (
<>
<Divider>Comments ({selectedPoll.comments.length})</Divider>
<List
size="small"
dataSource={selectedPoll.comments}
renderItem={(comment: any) => (
<List.Item
extra={
<Popconfirm title="Delete comment?" onConfirm={() => api.delete(`/straw-polls/${selectedPoll.id}/comments/${comment.id}`).then(() => fetchDetail(selectedPoll.id))}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
}
>
<List.Item.Meta title={comment.authorName} description={comment.content} />
<Text type="secondary" style={{ fontSize: 12 }}>{dayjs(comment.createdAt).format('MMM D, h:mm A')}</Text>
</List.Item>
)}
/>
</>
)}
</div>
)}
</Drawer>
</>
);
}

View File

@ -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<StrawPoll | null>(null);
const [loading, setLoading] = useState(true);
const [selectedOption, setSelectedOption] = useState<string>('');
const [voterName, setVoterName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [hasVoted, setHasVoted] = useState(false);
const [commentForm] = Form.useForm();
const sseRef = useRef<EventSource | null>(null);
const storedToken = localStorage.getItem(`straw_poll_voter_token_${slug}`);
const fetchPoll = useCallback(async () => {
if (!slug) return;
try {
const params: Record<string, string> = {};
if (storedToken) params.voterToken = storedToken;
const headers: Record<string, string> = {};
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<string, string> = { '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<string, string> = { '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 <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (!poll) return <Result status="404" title="Poll not found" />;
if (poll.requiresAuth && !user) return <Result status="403" title="This poll requires authentication" />;
const showVoteForm = poll.status === 'ACTIVE' && !hasVoted;
const showResults = poll.showResults || hasVoted;
return (
<div style={{ padding: isMobile ? 16 : 32, maxWidth: 700, margin: '0 auto' }}>
{/* Hero */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<Space>
<Tag>{STRAW_POLL_TYPE_LABELS[poll.type]}</Tag>
<Tag color={poll.status === 'ACTIVE' ? 'green' : poll.status === 'CLOSED' ? 'orange' : 'default'}>
{STRAW_POLL_STATUS_LABELS[poll.status]}
</Tag>
</Space>
<Title level={2} style={{ marginTop: 12, marginBottom: 8 }}>{poll.title}</Title>
{poll.description && <Paragraph type="secondary">{poll.description}</Paragraph>}
{poll.createdBy && <Text type="secondary">by {poll.createdBy.name}</Text>}
{poll.closesAt && poll.status === 'ACTIVE' && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">Closes {dayjs(poll.closesAt).format('MMM D, YYYY h:mm A')}</Text>
</div>
)}
</div>
{/* Vote Form */}
{showVoteForm && (
<Card style={{ marginBottom: 24 }}>
<Title level={4}>Cast Your Vote</Title>
{poll.type === 'YES_NO_ABSTAIN' ? (
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: '100%', justifyContent: 'center', marginBottom: 16 }}>
{poll.options?.map(opt => (
<Button
key={opt.id}
type={selectedOption === opt.id ? 'primary' : 'default'}
size="large"
onClick={() => setSelectedOption(opt.id)}
style={{
minWidth: 120,
...(opt.label === 'Yes' && selectedOption === opt.id ? { backgroundColor: '#52c41a', borderColor: '#52c41a' } : {}),
...(opt.label === 'No' && selectedOption === opt.id ? { backgroundColor: '#ff4d4f', borderColor: '#ff4d4f' } : {}),
}}
>
{opt.label}
</Button>
))}
</Space>
) : (
<Radio.Group
value={selectedOption}
onChange={(e) => setSelectedOption(e.target.value)}
style={{ width: '100%', marginBottom: 16 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{poll.options?.map(opt => (
<Radio key={opt.id} value={opt.id} style={{ fontSize: 16, padding: '8px 0' }}>
{opt.label}
</Radio>
))}
</Space>
</Radio.Group>
)}
{(poll.identityMode === 'ANONYMOUS' || poll.identityMode === 'MIXED') && !user && (
<Input
placeholder="Your name (optional)"
value={voterName}
onChange={(e) => setVoterName(e.target.value)}
style={{ marginBottom: 16, maxWidth: 300 }}
/>
)}
<Button
type="primary"
size="large"
disabled={!selectedOption}
loading={submitting}
onClick={handleVote}
block
>
Submit Vote
</Button>
</Card>
)}
{/* Already Voted */}
{hasVoted && poll.status === 'ACTIVE' && (
<Card style={{ marginBottom: 24, textAlign: 'center' }}>
<CheckCircleFilled style={{ fontSize: 32, color: '#52c41a', marginBottom: 8 }} />
<Title level={4} style={{ marginTop: 0 }}>You've voted!</Title>
<Text type="secondary">Your vote has been recorded.</Text>
</Card>
)}
{/* Results */}
{showResults && poll.options && (
<Card style={{ marginBottom: 24 }}>
<Title level={4}>Results</Title>
<PollResults
options={poll.options}
totalVotes={poll.totalVotes ?? poll._count?.votes ?? 0}
type={poll.type}
/>
</Card>
)}
{!showResults && poll.resultVisibility !== 'LIVE' && poll.resultVisibility !== 'PUBLIC_ALWAYS' && (
<Card style={{ marginBottom: 24, textAlign: 'center' }}>
<Text type="secondary">
Results will be visible {poll.resultVisibility === 'AFTER_VOTE' ? 'after you vote' : poll.resultVisibility === 'AFTER_CLOSE' ? 'when the poll closes' : ''}
</Text>
</Card>
)}
{/* Share */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Button
icon={<ShareAltOutlined />}
onClick={() => {
navigator.clipboard.writeText(window.location.href);
msg.success('Link copied!');
}}
>
Share This Poll
</Button>
</div>
{/* Comments */}
{poll.allowComments && (
<>
<Divider>Comments</Divider>
<Form form={commentForm} layout="inline" onFinish={handleComment} style={{ marginBottom: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Form.Item name="authorName" rules={[{ required: true, message: 'Name required' }]} style={{ flex: '0 0 auto' }}>
<Input placeholder="Your name" defaultValue={user?.name || ''} />
</Form.Item>
<Form.Item name="content" rules={[{ required: true, message: 'Comment required' }]} style={{ flex: 1, minWidth: 200 }}>
<Input placeholder="Add a comment..." />
</Form.Item>
<Button type="primary" htmlType="submit">Post</Button>
</Form>
{poll.comments && poll.comments.length > 0 ? (
<List
dataSource={poll.comments}
renderItem={(comment: any) => (
<List.Item>
<List.Item.Meta
title={comment.authorName}
description={comment.content}
/>
<Text type="secondary" style={{ fontSize: 12 }}>{dayjs(comment.createdAt).format('MMM D, h:mm A')}</Text>
</List.Item>
)}
/>
) : (
<Text type="secondary">No comments yet.</Text>
)}
</>
)}
</div>
);
}

View File

@ -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<StrawPoll[]>([]);
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 <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (polls.length === 0) return <Empty description="No active polls" style={{ marginTop: 80 }} />;
return (
<div style={{ padding: isMobile ? 16 : 32, maxWidth: 1000, margin: '0 auto' }}>
<Title level={2} style={{ textAlign: 'center', marginBottom: 32 }}>Straw Polls</Title>
<Row gutter={[16, 16]}>
{polls.map(poll => (
<Col key={poll.id} xs={24} sm={12} md={8}>
<Card
hoverable
onClick={() => navigate(`/straw-poll/${poll.slug}`)}
style={{ height: '100%' }}
>
<Tag style={{ marginBottom: 8 }}>{STRAW_POLL_TYPE_LABELS[poll.type]}</Tag>
<Title level={5} style={{ marginTop: 0, marginBottom: 8 }}>{poll.title}</Title>
{poll.description && (
<Paragraph ellipsis={{ rows: 2 }} type="secondary" style={{ marginBottom: 8 }}>
{poll.description}
</Paragraph>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">{poll._count?.votes ?? 0} votes</Text>
{poll.closesAt && (
<Text type="secondary">Closes {dayjs(poll.closesAt).fromNow()}</Text>
)}
</div>
</Card>
</Col>
))}
</Row>
</div>
);
}

View File

@ -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<StrawPollStatus, string> = {
DRAFT: 'default',
ACTIVE: 'green',
CLOSED: 'orange',
ARCHIVED: 'red',
};
export const STRAW_POLL_STATUS_LABELS: Record<StrawPollStatus, string> = {
DRAFT: 'Draft',
ACTIVE: 'Active',
CLOSED: 'Closed',
ARCHIVED: 'Archived',
};
export const STRAW_POLL_TYPE_LABELS: Record<StrawPollType, string> = {
SINGLE_CHOICE: 'Single Choice',
YES_NO_ABSTAIN: 'Yes / No / Abstain',
};
export const STRAW_POLL_IDENTITY_LABELS: Record<StrawPollIdentityMode, string> = {
ANONYMOUS: 'Anonymous',
TOKEN_GATED: 'Token-Gated',
AUTHENTICATED: 'Login Required',
MIXED: 'Mixed',
};
export const STRAW_POLL_VISIBILITY_LABELS: Record<StrawPollResultVisibility, string> = {
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;
}

View File

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

View File

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

View File

@ -0,0 +1,123 @@
import { Router, Request, Response, NextFunction } from 'express';
import { strawPollsService } from './polls.service';
import {
listStrawPollsSchema,
submitStrawPollVoteSchema,
submitStrawPollCommentSchema,
challengeVoteSchema,
} from './polls.schemas';
import { validate } from '../../middleware/validate';
import { authenticate, optionalAuth } from '../../middleware/auth.middleware';
import { strawPollVoteRateLimit, strawPollCommentRateLimit } from './polls.rate-limits';
import { pollSseService } from './polls-sse.service';
const publicRouter = Router();
// List active public polls
publicRouter.get('/public', validate(listStrawPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await strawPollsService.findAllPublic(req.query as any);
res.json(result);
} catch (err) { next(err); }
});
// Get poll by slug (public)
publicRouter.get('/public/:slug', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const voterToken = req.query.voterToken as string | undefined;
const clientIp = req.ip || req.socket.remoteAddress || '';
const poll = await strawPollsService.findBySlugPublic(slug, req.user?.id, voterToken, clientIp);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
// For AFTER_VOTE visibility, strip results if not voted
if ('resultVisibility' in poll && poll.resultVisibility === 'AFTER_VOTE' && !poll.hasVoted) {
const stripped = {
...poll,
options: (poll as any).options?.map((o: any) => ({ ...o, voteCount: undefined })),
totalVotes: undefined,
showResults: false,
};
return res.json(stripped);
}
res.json(poll);
} catch (err) { next(err); }
});
// Submit vote
publicRouter.post('/public/:slug/vote', optionalAuth, strawPollVoteRateLimit, validate(submitStrawPollVoteSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const clientIp = req.ip || req.socket.remoteAddress || '';
const result = await strawPollsService.submitVote(slug, req.body, req.user?.id, clientIp);
res.json(result);
} catch (err) {
if (err instanceof Error && (err.message.includes('not found') || err.message.includes('not active') || err.message.includes('Invalid option'))) {
return res.status(400).json({ error: err.message });
}
if (err instanceof Error && err.message.includes('required')) {
return res.status(401).json({ error: err.message });
}
next(err);
}
});
// Submit comment
publicRouter.post('/public/:slug/comment', optionalAuth, strawPollCommentRateLimit, validate(submitStrawPollCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const comment = await strawPollsService.addComment(slug, req.body, req.user?.id);
res.status(201).json(comment);
} catch (err) {
if (err instanceof Error && err.message.includes('disabled')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// SSE stream for live results
publicRouter.get('/public/:slug/live', (req: Request, res: Response) => {
const slug = req.params.slug as string;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
});
res.write(': connected\n\n');
const connectionId = pollSseService.addClient(slug, res);
if (!connectionId) {
res.write('event: error\ndata: {"message":"Too many connections"}\n\n');
res.end();
return;
}
req.on('close', () => {
pollSseService.removeClient(connectionId);
});
});
// Challenge a friend (requires auth)
publicRouter.post('/public/:slug/challenge', authenticate, validate(challengeVoteSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
// Look up poll ID from slug
const { prisma } = await import('../../config/database');
const poll = await prisma.strawPoll.findUnique({ where: { slug }, select: { id: true } });
if (!poll) return res.status(404).json({ error: 'Poll not found' });
const challenge = await strawPollsService.challengeFriend(poll.id, req.user!.id, req.body.challengedUserId);
res.status(201).json(challenge);
} catch (err) {
if (err instanceof Error && err.message.includes('not found')) {
return res.status(404).json({ error: err.message });
}
next(err);
}
});
export { publicRouter as strawPollPublicRouter };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = '<div style="border:1px solid rgba(255,255,255,0.15); border-radius:8px; padding:16px; max-width:400px; margin:12px auto;">';
html += '<div style="font-size:11px; text-transform:uppercase; opacity:0.5; margin-bottom:6px;">';
html += (poll.type === 'YES_NO_ABSTAIN' ? 'Yes / No / Abstain' : 'Single Choice') + ' Poll</div>';
html += '<h3 style="margin:0 0 8px; font-size:1.1rem;">' + poll.title + '</h3>';
html += '<div style="font-size:13px; opacity:0.65; margin-bottom:12px;">' + poll.totalVotes + ' vote' + (poll.totalVotes !== 1 ? 's' : '') + '</div>';
if (poll.status === 'ACTIVE') {
html += '<a href="' + appUrl + '/straw-poll/' + encodeURIComponent(poll.slug) + '" target="_blank" rel="noopener noreferrer" ';
html += 'style="display:inline-block; padding:10px 24px; background:#1890ff; color:#fff; text-decoration:none; border-radius:6px; font-weight:600; font-size:14px;">';
html += 'Vote Now &rarr;</a>';
} else {
html += '<span style="padding:3px 10px; border-radius:4px; font-size:12px; background:rgba(250,140,22,0.15); color:#fa8c16;">Closed</span>';
}
html += '</div>';
block.innerHTML = html;
}
// ===== Inline Mode =====
function renderInline(block, poll, apiUrl) {
var slug = poll.slug;
var storedToken = localStorage.getItem(tokenKey(slug));
var hasVoted = !!storedToken;
var showResults = poll.totalVotes > 0;
var html = '<div style="max-width:500px; margin:12px auto; border:1px solid rgba(255,255,255,0.15); border-radius:8px; padding:20px;">';
// Title
html += '<h3 style="margin:0 0 4px; font-size:1.1rem;">' + poll.title + '</h3>';
html += '<div style="font-size:11px; opacity:0.5; margin-bottom:14px;">';
html += (poll.type === 'YES_NO_ABSTAIN' ? 'Yes / No / Abstain' : 'Single Choice');
html += ' &middot; ' + poll.totalVotes + ' vote' + (poll.totalVotes !== 1 ? 's' : '') + '</div>';
if (poll.status === 'ACTIVE' && !hasVoted) {
// Vote form
html += '<div id="sp-vote-form-' + slug + '">';
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 += '<button class="sp-opt-btn" data-option-id="' + opt.id + '" style="display:block; width:100%; padding:10px 14px; margin-bottom:6px; ';
html += 'border:2px solid ' + color + '44; border-radius:6px; background:transparent; color:inherit; cursor:pointer; text-align:left; font-size:14px; transition:all 0.2s;" ';
html += 'onmouseover="this.style.background=\'' + color + '22\'" onmouseout="this.style.background=\'transparent\'">';
html += opt.label + '</button>';
});
html += '<input type="text" id="sp-voter-name-' + slug + '" placeholder="Your name (optional)" style="width:100%; padding:8px 12px; margin:8px 0; border:1px solid rgba(255,255,255,0.2); border-radius:4px; background:transparent; color:inherit; font-size:13px;" />';
html += '<button id="sp-submit-' + slug + '" disabled style="display:block; width:100%; padding:10px; border:none; border-radius:6px; background:#1890ff; color:#fff; font-weight:600; font-size:14px; cursor:pointer; opacity:0.5;">';
html += 'Submit Vote</button>';
html += '</div>';
}
// Results
if (showResults && (hasVoted || poll.status !== 'ACTIVE')) {
html += renderResultsHtml(poll);
}
if (hasVoted && poll.status === 'ACTIVE') {
html += '<div style="text-align:center; padding:8px; margin-top:8px; font-size:13px; color:#52c41a;">&#10003; You\'ve voted</div>';
}
html += '</div>';
block.innerHTML = html;
// Wire up vote form
if (poll.status === 'ACTIVE' && !hasVoted) {
wireVoteForm(block, poll, apiUrl, slug);
}
}
function renderResultsHtml(poll) {
var html = '<div style="margin-top:12px;">';
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 += '<div style="margin-bottom:8px;">';
html += '<div style="display:flex; justify-content:space-between; font-size:13px; margin-bottom:2px;">';
html += '<span>' + opt.label + '</span><span style="opacity:0.65;">' + opt.voteCount + ' (' + pct + '%)</span></div>';
html += '<div style="height:6px; background:rgba(255,255,255,0.1); border-radius:3px; overflow:hidden;">';
html += '<div style="height:100%; width:' + pct + '%; background:' + color + '; border-radius:3px; transition:width 0.3s;"></div>';
html += '</div></div>';
});
html += '</div>';
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 = '<div style="text-align:center; padding:16px; opacity:0.5;">Loading poll...</div>';
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 = '<div style="text-align:center; padding:16px; opacity:0.5;">Poll unavailable</div>';
});
});
// 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 = '<div style="text-align:center; padding:16px; opacity:0.5;">Loading...</div>';
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 = '<div style="text-align:center; padding:16px; opacity:0.5;">Poll unavailable</div>';
});
});
}
// 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); });
}
})();

View File

@ -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