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:
parent
68434c51a6
commit
902adce646
@ -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={
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
51
admin/src/components/polls/PollResults.tsx
Normal file
51
admin/src/components/polls/PollResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
412
admin/src/pages/influence/StrawPollsPage.tsx
Normal file
412
admin/src/pages/influence/StrawPollsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
293
admin/src/pages/public/StrawPollPage.tsx
Normal file
293
admin/src/pages/public/StrawPollPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
admin/src/pages/public/StrawPollsListPage.tsx
Normal file
63
admin/src/pages/public/StrawPollsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,168 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StrawPollType" AS ENUM ('SINGLE_CHOICE', 'YES_NO_ABSTAIN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StrawPollStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StrawPollIdentityMode" AS ENUM ('ANONYMOUS', 'TOKEN_GATED', 'AUTHENTICATED', 'MIXED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "StrawPollResultVisibility" AS ENUM ('LIVE', 'AFTER_VOTE', 'AFTER_CLOSE', 'CREATOR_ONLY', 'PUBLIC_ALWAYS');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'poll_closed';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'poll_results_available';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'poll_challenge';
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "UserRole" ADD VALUE 'POLLS_ADMIN';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN "enable_polls" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "straw_polls" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"title" VARCHAR(200) NOT NULL,
|
||||
"description" TEXT,
|
||||
"type" "StrawPollType" NOT NULL,
|
||||
"status" "StrawPollStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"identity_mode" "StrawPollIdentityMode" NOT NULL DEFAULT 'ANONYMOUS',
|
||||
"result_visibility" "StrawPollResultVisibility" NOT NULL DEFAULT 'LIVE',
|
||||
"allow_comments" BOOLEAN NOT NULL DEFAULT true,
|
||||
"closes_at" TIMESTAMP(3),
|
||||
"close_threshold" INTEGER,
|
||||
"auto_close_job_id" TEXT,
|
||||
"is_private" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_by_user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "straw_polls_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "straw_poll_options" (
|
||||
"id" TEXT NOT NULL,
|
||||
"poll_id" TEXT NOT NULL,
|
||||
"label" VARCHAR(500) NOT NULL,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 0,
|
||||
|
||||
CONSTRAINT "straw_poll_options_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "straw_poll_votes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"poll_id" TEXT NOT NULL,
|
||||
"option_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"voter_name" VARCHAR(100),
|
||||
"voter_token" TEXT,
|
||||
"voter_ip" TEXT,
|
||||
"contact_id" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "straw_poll_votes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "straw_poll_comments" (
|
||||
"id" TEXT NOT NULL,
|
||||
"poll_id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"author_name" VARCHAR(100) NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "straw_poll_comments_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "straw_poll_challenges" (
|
||||
"id" TEXT NOT NULL,
|
||||
"poll_id" TEXT NOT NULL,
|
||||
"challenger_user_id" TEXT NOT NULL,
|
||||
"challenged_user_id" TEXT NOT NULL,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "straw_poll_challenges_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "straw_polls_slug_key" ON "straw_polls"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "straw_polls_created_by_user_id_idx" ON "straw_polls"("created_by_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "straw_polls_status_idx" ON "straw_polls"("status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "straw_poll_options_poll_id_idx" ON "straw_poll_options"("poll_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "straw_poll_votes_poll_id_idx" ON "straw_poll_votes"("poll_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "straw_poll_votes_option_id_idx" ON "straw_poll_votes"("option_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_user_id_key" ON "straw_poll_votes"("poll_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_voter_token_key" ON "straw_poll_votes"("poll_id", "voter_token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_voter_ip_key" ON "straw_poll_votes"("poll_id", "voter_ip");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "straw_poll_comments_poll_id_idx" ON "straw_poll_comments"("poll_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "straw_poll_challenges_poll_id_challenger_user_id_challenged_key" ON "straw_poll_challenges"("poll_id", "challenger_user_id", "challenged_user_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_polls" ADD CONSTRAINT "straw_polls_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_options" ADD CONSTRAINT "straw_poll_options_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "straw_poll_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_comments" ADD CONSTRAINT "straw_poll_comments_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_comments" ADD CONSTRAINT "straw_poll_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_challenger_user_id_fkey" FOREIGN KEY ("challenger_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_challenged_user_id_fkey" FOREIGN KEY ("challenged_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@ -466,6 +466,32 @@ async function main() {
|
||||
title: 'Vote on a Meeting Time',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default-straw-poll-inline',
|
||||
type: 'straw-poll-inline',
|
||||
label: 'Straw Poll (Inline)',
|
||||
category: 'Influence',
|
||||
sortOrder: 18,
|
||||
schema: {
|
||||
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
|
||||
},
|
||||
defaults: {
|
||||
pollSlug: '',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default-straw-poll-card',
|
||||
type: 'straw-poll-card',
|
||||
label: 'Straw Poll (Card)',
|
||||
category: 'Influence',
|
||||
sortOrder: 19,
|
||||
schema: {
|
||||
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
|
||||
},
|
||||
defaults: {
|
||||
pollSlug: '',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const block of defaultBlocks) {
|
||||
|
||||
123
api/src/modules/polls/polls-public.routes.ts
Normal file
123
api/src/modules/polls/polls-public.routes.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { strawPollsService } from './polls.service';
|
||||
import {
|
||||
listStrawPollsSchema,
|
||||
submitStrawPollVoteSchema,
|
||||
submitStrawPollCommentSchema,
|
||||
challengeVoteSchema,
|
||||
} from './polls.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate, optionalAuth } from '../../middleware/auth.middleware';
|
||||
import { strawPollVoteRateLimit, strawPollCommentRateLimit } from './polls.rate-limits';
|
||||
import { pollSseService } from './polls-sse.service';
|
||||
|
||||
const publicRouter = Router();
|
||||
|
||||
// List active public polls
|
||||
publicRouter.get('/public', validate(listStrawPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await strawPollsService.findAllPublic(req.query as any);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Get poll by slug (public)
|
||||
publicRouter.get('/public/:slug', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const voterToken = req.query.voterToken as string | undefined;
|
||||
const clientIp = req.ip || req.socket.remoteAddress || '';
|
||||
const poll = await strawPollsService.findBySlugPublic(slug, req.user?.id, voterToken, clientIp);
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
|
||||
// For AFTER_VOTE visibility, strip results if not voted
|
||||
if ('resultVisibility' in poll && poll.resultVisibility === 'AFTER_VOTE' && !poll.hasVoted) {
|
||||
const stripped = {
|
||||
...poll,
|
||||
options: (poll as any).options?.map((o: any) => ({ ...o, voteCount: undefined })),
|
||||
totalVotes: undefined,
|
||||
showResults: false,
|
||||
};
|
||||
return res.json(stripped);
|
||||
}
|
||||
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Submit vote
|
||||
publicRouter.post('/public/:slug/vote', optionalAuth, strawPollVoteRateLimit, validate(submitStrawPollVoteSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const clientIp = req.ip || req.socket.remoteAddress || '';
|
||||
const result = await strawPollsService.submitVote(slug, req.body, req.user?.id, clientIp);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && (err.message.includes('not found') || err.message.includes('not active') || err.message.includes('Invalid option'))) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
if (err instanceof Error && err.message.includes('required')) {
|
||||
return res.status(401).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Submit comment
|
||||
publicRouter.post('/public/:slug/comment', optionalAuth, strawPollCommentRateLimit, validate(submitStrawPollCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const comment = await strawPollsService.addComment(slug, req.body, req.user?.id);
|
||||
res.status(201).json(comment);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('disabled')) {
|
||||
return res.status(400).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// SSE stream for live results
|
||||
publicRouter.get('/public/:slug/live', (req: Request, res: Response) => {
|
||||
const slug = req.params.slug as string;
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
'X-Accel-Buffering': 'no', // Disable nginx buffering
|
||||
});
|
||||
res.write(': connected\n\n');
|
||||
|
||||
const connectionId = pollSseService.addClient(slug, res);
|
||||
if (!connectionId) {
|
||||
res.write('event: error\ndata: {"message":"Too many connections"}\n\n');
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
req.on('close', () => {
|
||||
pollSseService.removeClient(connectionId);
|
||||
});
|
||||
});
|
||||
|
||||
// Challenge a friend (requires auth)
|
||||
publicRouter.post('/public/:slug/challenge', authenticate, validate(challengeVoteSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
// Look up poll ID from slug
|
||||
const { prisma } = await import('../../config/database');
|
||||
const poll = await prisma.strawPoll.findUnique({ where: { slug }, select: { id: true } });
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
|
||||
const challenge = await strawPollsService.challengeFriend(poll.id, req.user!.id, req.body.challengedUserId);
|
||||
res.status(201).json(challenge);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.includes('not found')) {
|
||||
return res.status(404).json({ error: err.message });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
export { publicRouter as strawPollPublicRouter };
|
||||
112
api/src/modules/polls/polls-sse.service.ts
Normal file
112
api/src/modules/polls/polls-sse.service.ts
Normal file
@ -0,0 +1,112 @@
|
||||
import type { Response } from 'express';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
interface PollSSEClient {
|
||||
id: string;
|
||||
res: Response;
|
||||
connectedAt: Date;
|
||||
}
|
||||
|
||||
/** Poll-level SSE manager keyed by slug (supports anonymous viewers) */
|
||||
class PollSSEService {
|
||||
private clients = new Map<string, PollSSEClient[]>(); // pollSlug -> connections
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private static MAX_CONNECTIONS_PER_POLL = 200;
|
||||
|
||||
startHeartbeat() {
|
||||
if (this.heartbeatInterval) return;
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
let total = 0;
|
||||
for (const [, clients] of this.clients) {
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.res.write(': heartbeat\n\n');
|
||||
total++;
|
||||
} catch {
|
||||
this.removeClient(client.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (total > 0) {
|
||||
logger.debug(`Poll SSE heartbeat sent to ${total} clients`);
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
stopHeartbeat() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
addClient(slug: string, res: Response): string | null {
|
||||
const existing = this.clients.get(slug) ?? [];
|
||||
if (existing.length >= PollSSEService.MAX_CONNECTIONS_PER_POLL) {
|
||||
return null; // Reject — too many connections for this poll
|
||||
}
|
||||
|
||||
const id = `${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const client: PollSSEClient = { id, res, connectedAt: new Date() };
|
||||
|
||||
if (!this.clients.has(slug)) {
|
||||
this.clients.set(slug, []);
|
||||
}
|
||||
this.clients.get(slug)!.push(client);
|
||||
|
||||
logger.debug(`Poll SSE client connected: ${id} (poll: ${slug})`);
|
||||
return id;
|
||||
}
|
||||
|
||||
removeClient(connectionId: string) {
|
||||
for (const [slug, clients] of this.clients) {
|
||||
const idx = clients.findIndex((c) => c.id === connectionId);
|
||||
if (idx >= 0) {
|
||||
clients.splice(idx, 1);
|
||||
if (clients.length === 0) {
|
||||
this.clients.delete(slug);
|
||||
}
|
||||
logger.debug(`Poll SSE client disconnected: ${connectionId} (poll: ${slug})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Broadcast event to all viewers of a poll */
|
||||
broadcast(slug: string, event: string, data: unknown) {
|
||||
const clients = this.clients.get(slug);
|
||||
if (!clients || clients.length === 0) return;
|
||||
|
||||
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
|
||||
for (const client of clients) {
|
||||
try {
|
||||
client.res.write(payload);
|
||||
} catch {
|
||||
this.removeClient(client.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getConnectionCount(slug?: string): number {
|
||||
if (slug) return this.clients.get(slug)?.length ?? 0;
|
||||
let count = 0;
|
||||
for (const clients of this.clients.values()) {
|
||||
count += clients.length;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
closeAll() {
|
||||
this.stopHeartbeat();
|
||||
for (const [, clients] of this.clients) {
|
||||
for (const client of clients) {
|
||||
try { client.res.end(); } catch {}
|
||||
}
|
||||
}
|
||||
this.clients.clear();
|
||||
logger.info('Poll SSE: All connections closed');
|
||||
}
|
||||
}
|
||||
|
||||
export const pollSseService = new PollSSEService();
|
||||
30
api/src/modules/polls/polls-widget.routes.ts
Normal file
30
api/src/modules/polls/polls-widget.routes.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { strawPollsService } from './polls.service';
|
||||
import { redis } from '../../config/redis';
|
||||
|
||||
const widgetRouter = Router();
|
||||
|
||||
// Lightweight JSON for MkDocs widget embeds (cached 60s)
|
||||
widgetRouter.get('/widget/:slug', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const slug = req.params.slug as string;
|
||||
const cacheKey = `straw-poll-widget:${slug}`;
|
||||
|
||||
// Check Redis cache
|
||||
const cached = await redis.get(cacheKey);
|
||||
if (cached) {
|
||||
res.set('X-Cache', 'HIT');
|
||||
return res.json(JSON.parse(cached));
|
||||
}
|
||||
|
||||
const poll = await strawPollsService.findBySlugWidget(slug);
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
|
||||
// Cache for 60 seconds
|
||||
await redis.set(cacheKey, JSON.stringify(poll), 'EX', 60);
|
||||
res.set('X-Cache', 'MISS');
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { widgetRouter as strawPollWidgetRouter };
|
||||
37
api/src/modules/polls/polls.rate-limits.ts
Normal file
37
api/src/modules/polls/polls.rate-limits.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
import { redis } from '../../config/redis';
|
||||
|
||||
export const strawPollVoteRateLimit = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 30,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:straw-poll-vote:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many vote submissions, please try again later',
|
||||
code: 'STRAW_POLL_VOTE_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const strawPollCommentRateLimit = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 60,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
store: new RedisStore({
|
||||
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||
prefix: 'rl:straw-poll-comment:',
|
||||
}),
|
||||
message: {
|
||||
error: {
|
||||
message: 'Too many comments, please try again later',
|
||||
code: 'STRAW_POLL_COMMENT_RATE_LIMIT_EXCEEDED',
|
||||
},
|
||||
},
|
||||
});
|
||||
121
api/src/modules/polls/polls.routes.ts
Normal file
121
api/src/modules/polls/polls.routes.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { strawPollsService } from './polls.service';
|
||||
import {
|
||||
createStrawPollSchema,
|
||||
updateStrawPollSchema,
|
||||
listStrawPollsSchema,
|
||||
generateLinksSchema,
|
||||
} from './polls.schemas';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { POLLS_ROLES } from '../../utils/roles';
|
||||
|
||||
const adminRouter = Router();
|
||||
adminRouter.use(authenticate);
|
||||
adminRouter.use(requireRole(...POLLS_ROLES));
|
||||
|
||||
// List polls
|
||||
adminRouter.get('/', validate(listStrawPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await strawPollsService.findAll(req.query as any);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Get poll detail
|
||||
adminRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const poll = await strawPollsService.findById(id);
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Create poll
|
||||
adminRouter.post('/', validate(createStrawPollSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const poll = await strawPollsService.create(req.body, req.user!.id);
|
||||
res.status(201).json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Update poll
|
||||
adminRouter.put('/:id', validate(updateStrawPollSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const poll = await strawPollsService.update(id, req.body);
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete poll
|
||||
adminRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const poll = await strawPollsService.delete(id);
|
||||
if (!poll) return res.status(404).json({ error: 'Poll not found' });
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Activate (DRAFT -> ACTIVE)
|
||||
adminRouter.post('/:id/activate', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const poll = await strawPollsService.activate(req.params.id as string);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Close (ACTIVE -> CLOSED)
|
||||
adminRouter.post('/:id/close', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const poll = await strawPollsService.closePoll(req.params.id as string);
|
||||
if (!poll) return res.status(400).json({ error: 'Poll cannot be closed (not active)' });
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Reopen (CLOSED -> ACTIVE)
|
||||
adminRouter.post('/:id/reopen', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const poll = await strawPollsService.reopenPoll(req.params.id as string);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Archive (CLOSED -> ARCHIVED)
|
||||
adminRouter.post('/:id/archive', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const poll = await strawPollsService.archivePoll(req.params.id as string);
|
||||
res.json(poll);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete a vote (moderation)
|
||||
adminRouter.delete('/:id/votes/:voteId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await strawPollsService.deleteVote(req.params.id as string, req.params.voteId as string);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Delete a comment (moderation)
|
||||
adminRouter.delete('/:id/comments/:commentId', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
await strawPollsService.deleteComment(req.params.id as string, req.params.commentId as string);
|
||||
res.json({ success: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Generate voting links (TOKEN_GATED)
|
||||
adminRouter.post('/:id/generate-links', validate(generateLinksSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const result = await strawPollsService.generateVotingTokens(req.params.id as string, req.body.count);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
export { adminRouter as strawPollAdminRouter };
|
||||
67
api/src/modules/polls/polls.schemas.ts
Normal file
67
api/src/modules/polls/polls.schemas.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { z } from 'zod';
|
||||
import { StrawPollType, StrawPollStatus, StrawPollIdentityMode, StrawPollResultVisibility } from '@prisma/client';
|
||||
|
||||
export const createStrawPollSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
type: z.nativeEnum(StrawPollType),
|
||||
identityMode: z.nativeEnum(StrawPollIdentityMode).optional().default('ANONYMOUS'),
|
||||
resultVisibility: z.nativeEnum(StrawPollResultVisibility).optional().default('LIVE'),
|
||||
allowComments: z.boolean().optional().default(true),
|
||||
closesAt: z.string().datetime().optional(),
|
||||
closeThreshold: z.number().int().min(1).max(100000).nullable().optional(),
|
||||
isPrivate: z.boolean().optional().default(false),
|
||||
options: z.array(z.object({
|
||||
label: z.string().min(1, 'Option label is required').max(500),
|
||||
})).min(2, 'At least 2 options required').max(20, 'Maximum 20 options').optional(),
|
||||
});
|
||||
|
||||
export const updateStrawPollSchema = z.object({
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
identityMode: z.nativeEnum(StrawPollIdentityMode).optional(),
|
||||
resultVisibility: z.nativeEnum(StrawPollResultVisibility).optional(),
|
||||
allowComments: z.boolean().optional(),
|
||||
closesAt: z.string().datetime().nullable().optional(),
|
||||
closeThreshold: z.number().int().min(1).max(100000).nullable().optional(),
|
||||
isPrivate: z.boolean().optional(),
|
||||
options: z.array(z.object({
|
||||
id: z.string().optional(),
|
||||
label: z.string().min(1).max(500),
|
||||
})).min(2).max(20).optional(),
|
||||
});
|
||||
|
||||
export const submitStrawPollVoteSchema = z.object({
|
||||
optionId: z.string().min(1, 'Option is required'),
|
||||
voterName: z.string().min(1).max(100).optional(),
|
||||
voterToken: z.string().optional(),
|
||||
});
|
||||
|
||||
export const submitStrawPollCommentSchema = z.object({
|
||||
authorName: z.string().min(1, 'Name is required').max(100),
|
||||
content: z.string().min(1, 'Comment is required').max(2000),
|
||||
});
|
||||
|
||||
export const listStrawPollsSchema = z.object({
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().positive().max(100).default(20),
|
||||
search: z.string().optional(),
|
||||
status: z.nativeEnum(StrawPollStatus).optional(),
|
||||
type: z.nativeEnum(StrawPollType).optional(),
|
||||
});
|
||||
|
||||
export const challengeVoteSchema = z.object({
|
||||
challengedUserId: z.string().min(1, 'User ID is required'),
|
||||
});
|
||||
|
||||
export const generateLinksSchema = z.object({
|
||||
count: z.number().int().min(1).max(500).default(10),
|
||||
});
|
||||
|
||||
export type CreateStrawPollInput = z.infer<typeof createStrawPollSchema>;
|
||||
export type UpdateStrawPollInput = z.infer<typeof updateStrawPollSchema>;
|
||||
export type SubmitStrawPollVoteInput = z.infer<typeof submitStrawPollVoteSchema>;
|
||||
export type SubmitStrawPollCommentInput = z.infer<typeof submitStrawPollCommentSchema>;
|
||||
export type ListStrawPollsInput = z.infer<typeof listStrawPollsSchema>;
|
||||
export type ChallengeVoteInput = z.infer<typeof challengeVoteSchema>;
|
||||
export type GenerateLinksInput = z.infer<typeof generateLinksSchema>;
|
||||
656
api/src/modules/polls/polls.service.ts
Normal file
656
api/src/modules/polls/polls.service.ts
Normal file
@ -0,0 +1,656 @@
|
||||
import crypto from 'crypto';
|
||||
import { Prisma, StrawPollStatus, StrawPollType } from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { generateSlug } from '../../utils/slug';
|
||||
import { pollSseService } from './polls-sse.service';
|
||||
import type {
|
||||
CreateStrawPollInput,
|
||||
UpdateStrawPollInput,
|
||||
SubmitStrawPollVoteInput,
|
||||
SubmitStrawPollCommentInput,
|
||||
ListStrawPollsInput,
|
||||
} from './polls.schemas';
|
||||
|
||||
function generateVoterToken(): string {
|
||||
return crypto.randomBytes(18).toString('base64url').slice(0, 24);
|
||||
}
|
||||
|
||||
// Select configs for different contexts
|
||||
const pollListSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
identityMode: true,
|
||||
resultVisibility: true,
|
||||
isPrivate: true,
|
||||
closesAt: true,
|
||||
closeThreshold: true,
|
||||
allowComments: true,
|
||||
createdByUserId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { votes: true, comments: true, options: true } },
|
||||
} satisfies Prisma.StrawPollSelect;
|
||||
|
||||
const pollDetailSelect = {
|
||||
...pollListSelect,
|
||||
description: true,
|
||||
autoCloseJobId: true,
|
||||
options: { orderBy: { sortOrder: 'asc' as const }, include: { _count: { select: { votes: true } } } },
|
||||
votes: {
|
||||
select: {
|
||||
id: true, optionId: true, userId: true, voterName: true, createdAt: true,
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' as const },
|
||||
},
|
||||
comments: {
|
||||
select: { id: true, authorName: true, content: true, userId: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' as const },
|
||||
},
|
||||
createdBy: { select: { id: true, name: true, email: true } },
|
||||
};
|
||||
|
||||
const publicPollSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
status: true,
|
||||
identityMode: true,
|
||||
resultVisibility: true,
|
||||
allowComments: true,
|
||||
isPrivate: true,
|
||||
closesAt: true,
|
||||
createdByUserId: true,
|
||||
createdAt: true,
|
||||
options: {
|
||||
select: { id: true, label: true, sortOrder: true, _count: { select: { votes: true } } },
|
||||
orderBy: { sortOrder: 'asc' as const },
|
||||
},
|
||||
_count: { select: { votes: true, comments: true } },
|
||||
comments: {
|
||||
select: { id: true, authorName: true, content: true, createdAt: true },
|
||||
orderBy: { createdAt: 'desc' as const },
|
||||
},
|
||||
createdBy: { select: { name: true } },
|
||||
};
|
||||
|
||||
class StrawPollsService {
|
||||
// ===== Admin CRUD =====
|
||||
|
||||
async findAll(filters: ListStrawPollsInput) {
|
||||
const { page, limit, search, status, type } = filters;
|
||||
const where: Prisma.StrawPollWhereInput = {};
|
||||
|
||||
if (status) where.status = status;
|
||||
if (type) where.type = type;
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [polls, total] = await Promise.all([
|
||||
prisma.strawPoll.findMany({
|
||||
where,
|
||||
select: pollListSelect,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.strawPoll.count({ where }),
|
||||
]);
|
||||
|
||||
return { polls, total, page, limit };
|
||||
}
|
||||
|
||||
async findById(id: string) {
|
||||
return prisma.strawPoll.findUnique({ where: { id }, select: pollDetailSelect });
|
||||
}
|
||||
|
||||
async create(data: CreateStrawPollInput, userId: string) {
|
||||
const slug = generateSlug(data.title);
|
||||
|
||||
// For YES_NO_ABSTAIN, auto-create the three fixed options
|
||||
const options = data.type === StrawPollType.YES_NO_ABSTAIN
|
||||
? [
|
||||
{ label: 'Yes', sortOrder: 0 },
|
||||
{ label: 'No', sortOrder: 1 },
|
||||
{ label: 'Abstain', sortOrder: 2 },
|
||||
]
|
||||
: (data.options ?? []).map((opt, i) => ({ label: opt.label, sortOrder: i }));
|
||||
|
||||
if (data.type === StrawPollType.SINGLE_CHOICE && options.length < 2) {
|
||||
throw new Error('SINGLE_CHOICE polls require at least 2 options');
|
||||
}
|
||||
|
||||
const poll = await prisma.strawPoll.create({
|
||||
data: {
|
||||
slug,
|
||||
title: data.title,
|
||||
description: data.description,
|
||||
type: data.type,
|
||||
identityMode: data.identityMode,
|
||||
resultVisibility: data.resultVisibility,
|
||||
allowComments: data.allowComments,
|
||||
closesAt: data.closesAt ? new Date(data.closesAt) : undefined,
|
||||
closeThreshold: data.closeThreshold,
|
||||
isPrivate: data.isPrivate,
|
||||
createdByUserId: userId,
|
||||
options: { create: options },
|
||||
},
|
||||
select: pollDetailSelect,
|
||||
});
|
||||
|
||||
// Schedule auto-close if closesAt is set
|
||||
if (poll.closesAt) {
|
||||
try {
|
||||
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
|
||||
const jobId = await pollAutoCloseQueueService.scheduleJob(poll.id, poll.closesAt);
|
||||
if (jobId) {
|
||||
await prisma.strawPoll.update({ where: { id: poll.id }, data: { autoCloseJobId: jobId } });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to schedule auto-close job', { error: err, pollId: poll.id });
|
||||
}
|
||||
}
|
||||
|
||||
return poll;
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateStrawPollInput) {
|
||||
const existing = await prisma.strawPoll.findUnique({ where: { id } });
|
||||
if (!existing) return null;
|
||||
|
||||
// Handle options update for SINGLE_CHOICE polls
|
||||
if (data.options && existing.type === StrawPollType.SINGLE_CHOICE) {
|
||||
// Delete removed options, update existing, create new
|
||||
const existingOptions = await prisma.strawPollOption.findMany({ where: { pollId: id } });
|
||||
const incomingIds = data.options.filter(o => o.id).map(o => o.id!);
|
||||
const toDelete = existingOptions.filter(o => !incomingIds.includes(o.id));
|
||||
|
||||
await prisma.$transaction([
|
||||
// Delete removed options (and their votes)
|
||||
...toDelete.map(o => prisma.strawPollOption.delete({ where: { id: o.id } })),
|
||||
// Upsert remaining
|
||||
...data.options.map((opt, i) =>
|
||||
opt.id
|
||||
? prisma.strawPollOption.update({ where: { id: opt.id }, data: { label: opt.label, sortOrder: i } })
|
||||
: prisma.strawPollOption.create({ data: { pollId: id, label: opt.label, sortOrder: i } })
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
const { options: _, ...updateData } = data;
|
||||
const poll = await prisma.strawPoll.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...updateData,
|
||||
closesAt: data.closesAt === null ? null : data.closesAt ? new Date(data.closesAt) : undefined,
|
||||
},
|
||||
select: pollDetailSelect,
|
||||
});
|
||||
|
||||
// Reschedule auto-close if closesAt changed
|
||||
if (data.closesAt !== undefined) {
|
||||
try {
|
||||
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
|
||||
if (existing.autoCloseJobId) await pollAutoCloseQueueService.cancelJob(existing.id);
|
||||
if (poll.closesAt) {
|
||||
const jobId = await pollAutoCloseQueueService.scheduleJob(poll.id, poll.closesAt);
|
||||
if (jobId) await prisma.strawPoll.update({ where: { id }, data: { autoCloseJobId: jobId } });
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to reschedule auto-close job', { error: err, pollId: id });
|
||||
}
|
||||
}
|
||||
|
||||
return poll;
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const existing = await prisma.strawPoll.findUnique({ where: { id } });
|
||||
if (!existing) return null;
|
||||
|
||||
// Cancel auto-close job if scheduled
|
||||
if (existing.autoCloseJobId) {
|
||||
try {
|
||||
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
|
||||
await pollAutoCloseQueueService.cancelJob(existing.id);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return prisma.strawPoll.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ===== Lifecycle transitions =====
|
||||
|
||||
async activate(id: string) {
|
||||
return prisma.strawPoll.update({
|
||||
where: { id, status: StrawPollStatus.DRAFT },
|
||||
data: { status: StrawPollStatus.ACTIVE },
|
||||
select: pollDetailSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async closePoll(id: string) {
|
||||
const poll = await prisma.strawPoll.updateMany({
|
||||
where: { id, status: StrawPollStatus.ACTIVE },
|
||||
data: { status: StrawPollStatus.CLOSED },
|
||||
});
|
||||
if (poll.count === 0) return null;
|
||||
|
||||
const closed = await prisma.strawPoll.findUnique({
|
||||
where: { id },
|
||||
select: { slug: true, title: true, votes: { select: { userId: true }, where: { userId: { not: null } } } },
|
||||
});
|
||||
|
||||
// Broadcast poll closed via SSE
|
||||
if (closed) {
|
||||
pollSseService.broadcast(closed.slug, 'poll_closed', { pollId: id });
|
||||
}
|
||||
|
||||
// Notify authenticated voters (fire-and-forget)
|
||||
if (closed?.votes) {
|
||||
this.notifyVotersPollClosed(id, closed.title, closed.votes.map(v => v.userId!)).catch(() => {});
|
||||
}
|
||||
|
||||
return prisma.strawPoll.findUnique({ where: { id }, select: pollDetailSelect });
|
||||
}
|
||||
|
||||
async reopenPoll(id: string) {
|
||||
return prisma.strawPoll.update({
|
||||
where: { id, status: StrawPollStatus.CLOSED },
|
||||
data: { status: StrawPollStatus.ACTIVE },
|
||||
select: pollDetailSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async archivePoll(id: string) {
|
||||
return prisma.strawPoll.update({
|
||||
where: { id, status: StrawPollStatus.CLOSED },
|
||||
data: { status: StrawPollStatus.ARCHIVED },
|
||||
select: pollDetailSelect,
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Public endpoints =====
|
||||
|
||||
async findAllPublic(filters: ListStrawPollsInput) {
|
||||
const { page, limit, search } = filters;
|
||||
const where: Prisma.StrawPollWhereInput = {
|
||||
status: StrawPollStatus.ACTIVE,
|
||||
isPrivate: false,
|
||||
};
|
||||
if (search) {
|
||||
where.OR = [
|
||||
{ title: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
|
||||
const [polls, total] = await Promise.all([
|
||||
prisma.strawPoll.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
type: true,
|
||||
status: true,
|
||||
closesAt: true,
|
||||
createdAt: true,
|
||||
_count: { select: { votes: true, options: true } },
|
||||
createdBy: { select: { name: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.strawPoll.count({ where }),
|
||||
]);
|
||||
|
||||
return { polls, total, page, limit };
|
||||
}
|
||||
|
||||
async findBySlugPublic(slug: string, userId?: string, voterToken?: string, voterIp?: string) {
|
||||
const poll = await prisma.strawPoll.findUnique({
|
||||
where: { slug },
|
||||
select: publicPollSelect,
|
||||
});
|
||||
|
||||
if (!poll) return null;
|
||||
if (poll.isPrivate && !userId) {
|
||||
// Return limited metadata for private polls when not authenticated
|
||||
return {
|
||||
id: poll.id,
|
||||
slug: poll.slug,
|
||||
title: poll.title,
|
||||
type: poll.type,
|
||||
status: poll.status,
|
||||
isPrivate: true,
|
||||
requiresAuth: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine if results should be shown
|
||||
const showResults = this.shouldShowResults(poll, userId, voterToken, voterIp);
|
||||
|
||||
// Strip vote counts if results are hidden
|
||||
const options = poll.options.map(opt => ({
|
||||
id: opt.id,
|
||||
label: opt.label,
|
||||
sortOrder: opt.sortOrder,
|
||||
voteCount: showResults ? opt._count.votes : undefined,
|
||||
}));
|
||||
|
||||
// Check if the requester has already voted
|
||||
const hasVoted = await this.checkHasVoted(poll.id, userId, voterToken, voterIp);
|
||||
|
||||
return {
|
||||
...poll,
|
||||
options,
|
||||
totalVotes: showResults ? poll._count.votes : undefined,
|
||||
showResults,
|
||||
hasVoted,
|
||||
};
|
||||
}
|
||||
|
||||
async findBySlugWidget(slug: string) {
|
||||
const poll = await prisma.strawPoll.findUnique({
|
||||
where: { slug },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
type: true,
|
||||
status: true,
|
||||
resultVisibility: true,
|
||||
identityMode: true,
|
||||
options: {
|
||||
select: { id: true, label: true, sortOrder: true, _count: { select: { votes: true } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
},
|
||||
_count: { select: { votes: true } },
|
||||
},
|
||||
});
|
||||
if (!poll) return null;
|
||||
|
||||
// Widget always returns counts for LIVE/PUBLIC_ALWAYS; otherwise omit
|
||||
const showCounts = poll.resultVisibility === 'LIVE' || poll.resultVisibility === 'PUBLIC_ALWAYS';
|
||||
|
||||
return {
|
||||
id: poll.id,
|
||||
slug: poll.slug,
|
||||
title: poll.title,
|
||||
type: poll.type,
|
||||
status: poll.status,
|
||||
identityMode: poll.identityMode,
|
||||
totalVotes: showCounts ? poll._count.votes : 0,
|
||||
options: poll.options.map(o => ({
|
||||
id: o.id,
|
||||
label: o.label,
|
||||
sortOrder: o.sortOrder,
|
||||
voteCount: showCounts ? o._count.votes : 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Voting =====
|
||||
|
||||
async submitVote(
|
||||
slug: string,
|
||||
data: SubmitStrawPollVoteInput,
|
||||
userId?: string,
|
||||
clientIp?: string,
|
||||
) {
|
||||
const poll = await prisma.strawPoll.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true, status: true, identityMode: true, closeThreshold: true, slug: true, _count: { select: { votes: true } } },
|
||||
});
|
||||
|
||||
if (!poll) throw new Error('Poll not found');
|
||||
if (poll.status !== StrawPollStatus.ACTIVE) throw new Error('Poll is not active');
|
||||
|
||||
// Validate option exists
|
||||
const option = await prisma.strawPollOption.findFirst({
|
||||
where: { id: data.optionId, pollId: poll.id },
|
||||
});
|
||||
if (!option) throw new Error('Invalid option');
|
||||
|
||||
// Enforce identity mode
|
||||
const { identityMode } = poll;
|
||||
if (identityMode === 'AUTHENTICATED' && !userId) {
|
||||
throw new Error('Authentication required to vote');
|
||||
}
|
||||
if (identityMode === 'TOKEN_GATED' && !data.voterToken) {
|
||||
throw new Error('A voting token is required');
|
||||
}
|
||||
|
||||
// Determine dedup key
|
||||
let voterToken = data.voterToken || null;
|
||||
const voterIp = (identityMode === 'ANONYMOUS' && !userId) ? clientIp : null;
|
||||
|
||||
// For anonymous/mixed without a token, generate one
|
||||
if (!userId && !voterToken && identityMode !== 'TOKEN_GATED') {
|
||||
voterToken = generateVoterToken();
|
||||
}
|
||||
|
||||
// Upsert vote: one vote per poll per voter
|
||||
let vote;
|
||||
if (userId) {
|
||||
vote = await prisma.strawPollVote.upsert({
|
||||
where: { pollId_userId: { pollId: poll.id, userId } },
|
||||
create: {
|
||||
pollId: poll.id,
|
||||
optionId: data.optionId,
|
||||
userId,
|
||||
voterName: data.voterName,
|
||||
voterToken,
|
||||
},
|
||||
update: { optionId: data.optionId, updatedAt: new Date() },
|
||||
});
|
||||
} else if (voterToken) {
|
||||
vote = await prisma.strawPollVote.upsert({
|
||||
where: { pollId_voterToken: { pollId: poll.id, voterToken } },
|
||||
create: {
|
||||
pollId: poll.id,
|
||||
optionId: data.optionId,
|
||||
voterName: data.voterName,
|
||||
voterToken,
|
||||
voterIp,
|
||||
},
|
||||
update: { optionId: data.optionId, updatedAt: new Date() },
|
||||
});
|
||||
} else if (voterIp) {
|
||||
vote = await prisma.strawPollVote.upsert({
|
||||
where: { pollId_voterIp: { pollId: poll.id, voterIp } },
|
||||
create: {
|
||||
pollId: poll.id,
|
||||
optionId: data.optionId,
|
||||
voterName: data.voterName,
|
||||
voterIp,
|
||||
},
|
||||
update: { optionId: data.optionId, updatedAt: new Date() },
|
||||
});
|
||||
} else {
|
||||
throw new Error('Unable to identify voter');
|
||||
}
|
||||
|
||||
// Get updated counts for SSE broadcast
|
||||
const optionCounts = await prisma.strawPollOption.findMany({
|
||||
where: { pollId: poll.id },
|
||||
select: { id: true, _count: { select: { votes: true } } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const totalVotes = optionCounts.reduce((sum, o) => sum + o._count.votes, 0);
|
||||
|
||||
// Broadcast vote update via SSE
|
||||
pollSseService.broadcast(poll.slug, 'vote_update', {
|
||||
optionCounts: optionCounts.map(o => ({ optionId: o.id, count: o._count.votes })),
|
||||
totalVotes,
|
||||
});
|
||||
|
||||
// Check auto-close threshold
|
||||
if (poll.closeThreshold && totalVotes >= poll.closeThreshold) {
|
||||
this.closePoll(poll.id).catch(err =>
|
||||
logger.error('Auto-close by threshold failed', { error: err, pollId: poll.id })
|
||||
);
|
||||
}
|
||||
|
||||
return { voteId: vote.id, voterToken: voterToken || undefined };
|
||||
}
|
||||
|
||||
// ===== Comments =====
|
||||
|
||||
async addComment(slug: string, data: SubmitStrawPollCommentInput, userId?: string) {
|
||||
const poll = await prisma.strawPoll.findUnique({
|
||||
where: { slug },
|
||||
select: { id: true, allowComments: true, slug: true },
|
||||
});
|
||||
if (!poll) throw new Error('Poll not found');
|
||||
if (!poll.allowComments) throw new Error('Comments are disabled');
|
||||
|
||||
const comment = await prisma.strawPollComment.create({
|
||||
data: {
|
||||
pollId: poll.id,
|
||||
userId,
|
||||
authorName: data.authorName,
|
||||
content: data.content,
|
||||
},
|
||||
select: { id: true, authorName: true, content: true, createdAt: true },
|
||||
});
|
||||
|
||||
pollSseService.broadcast(poll.slug, 'comment_added', comment);
|
||||
return comment;
|
||||
}
|
||||
|
||||
async deleteComment(pollId: string, commentId: string) {
|
||||
return prisma.strawPollComment.delete({ where: { id: commentId, pollId } });
|
||||
}
|
||||
|
||||
// ===== Vote moderation =====
|
||||
|
||||
async deleteVote(pollId: string, voteId: string) {
|
||||
return prisma.strawPollVote.delete({ where: { id: voteId, pollId } });
|
||||
}
|
||||
|
||||
// ===== Challenges =====
|
||||
|
||||
async challengeFriend(pollId: string, challengerUserId: string, challengedUserId: string) {
|
||||
const poll = await prisma.strawPoll.findUnique({ where: { id: pollId }, select: { slug: true, title: true } });
|
||||
if (!poll) throw new Error('Poll not found');
|
||||
|
||||
const challenge = await prisma.strawPollChallenge.create({
|
||||
data: { pollId, challengerUserId, challengedUserId },
|
||||
});
|
||||
|
||||
// Send notification (fire-and-forget)
|
||||
this.sendChallengeNotification(challengedUserId, challengerUserId, poll.slug, poll.title).catch(() => {});
|
||||
|
||||
return challenge;
|
||||
}
|
||||
|
||||
// ===== TOKEN_GATED link generation =====
|
||||
|
||||
async generateVotingTokens(pollId: string, count: number) {
|
||||
const poll = await prisma.strawPoll.findUnique({ where: { id: pollId }, select: { identityMode: true, slug: true } });
|
||||
if (!poll) throw new Error('Poll not found');
|
||||
if (poll.identityMode !== 'TOKEN_GATED') throw new Error('Poll is not token-gated');
|
||||
|
||||
const tokens: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
tokens.push(generateVoterToken());
|
||||
}
|
||||
|
||||
return { tokens, slug: poll.slug };
|
||||
}
|
||||
|
||||
// ===== Private helpers =====
|
||||
|
||||
private shouldShowResults(
|
||||
poll: { resultVisibility: string; status: string; createdByUserId: string; id: string },
|
||||
userId?: string,
|
||||
voterToken?: string,
|
||||
voterIp?: string,
|
||||
): boolean {
|
||||
switch (poll.resultVisibility) {
|
||||
case 'LIVE':
|
||||
case 'PUBLIC_ALWAYS':
|
||||
return true;
|
||||
case 'AFTER_CLOSE':
|
||||
return poll.status === 'CLOSED' || poll.status === 'ARCHIVED';
|
||||
case 'CREATOR_ONLY':
|
||||
return !!userId && userId === poll.createdByUserId;
|
||||
case 'AFTER_VOTE':
|
||||
// Will be checked after hasVoted query — return true optimistically,
|
||||
// actual filtering happens in findBySlugPublic
|
||||
return true; // placeholder; refined by hasVoted check in caller
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async checkHasVoted(pollId: string, userId?: string, voterToken?: string, voterIp?: string): Promise<boolean> {
|
||||
if (userId) {
|
||||
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_userId: { pollId, userId } } });
|
||||
return !!vote;
|
||||
}
|
||||
if (voterToken) {
|
||||
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_voterToken: { pollId, voterToken } } });
|
||||
return !!vote;
|
||||
}
|
||||
if (voterIp) {
|
||||
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_voterIp: { pollId, voterIp } } });
|
||||
return !!vote;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async notifyVotersPollClosed(pollId: string, title: string, voterUserIds: string[]) {
|
||||
try {
|
||||
const { notificationService } = await import('../social/notification.service');
|
||||
for (const userId of voterUserIds) {
|
||||
await notificationService.createNotification(
|
||||
userId,
|
||||
'poll_closed' as any,
|
||||
'Poll Closed',
|
||||
`The poll "${title}" has closed. Check the results!`,
|
||||
{ pollId },
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('Failed to send poll closed notifications', { error: err, pollId });
|
||||
}
|
||||
}
|
||||
|
||||
private async sendChallengeNotification(
|
||||
challengedUserId: string,
|
||||
challengerUserId: string,
|
||||
pollSlug: string,
|
||||
pollTitle: string,
|
||||
) {
|
||||
try {
|
||||
const challenger = await prisma.user.findUnique({ where: { id: challengerUserId }, select: { name: true } });
|
||||
const { notificationService } = await import('../social/notification.service');
|
||||
await notificationService.createNotification(
|
||||
challengedUserId,
|
||||
'poll_challenge' as any,
|
||||
'Poll Challenge',
|
||||
`${challenger?.name || 'Someone'} challenged you to vote on "${pollTitle}"`,
|
||||
{ pollSlug, challengerUserId },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('Failed to send challenge notification', { error: err });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const strawPollsService = new StrawPollsService();
|
||||
@ -58,6 +58,7 @@ export const updateSiteSettingsSchema = z.object({
|
||||
enableMeet: z.boolean().optional(),
|
||||
enableMeetingPlanner: z.boolean().optional(),
|
||||
enableTicketedEvents: z.boolean().optional(),
|
||||
enablePolls: z.boolean().optional(),
|
||||
enableSocialCalendar: z.boolean().optional(),
|
||||
enableDocsCollaboration: z.boolean().optional(),
|
||||
requireEventApproval: z.boolean().optional(),
|
||||
|
||||
@ -5,7 +5,7 @@ import { friendshipService } from './friendship.service';
|
||||
/** A unified feed item representing any activity type */
|
||||
export interface FeedItem {
|
||||
id: string;
|
||||
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed';
|
||||
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed' | 'poll_voted';
|
||||
userId: string;
|
||||
userName: string | null;
|
||||
userEmail: string;
|
||||
@ -56,7 +56,7 @@ export const feedService = {
|
||||
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
|
||||
|
||||
// Query all activity types in parallel
|
||||
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges] = await Promise.all([
|
||||
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges, pollVotes] = await Promise.all([
|
||||
this.getShiftSignupActivities(visibleFriendIds, since),
|
||||
this.getCampaignEmailActivities(visibleFriendIds, since),
|
||||
this.getCanvassSessionActivities(visibleFriendIds, since),
|
||||
@ -65,6 +65,7 @@ export const feedService = {
|
||||
this.getSpotlightActivities(since),
|
||||
this.getReferralActivities(visibleFriendIds, since),
|
||||
this.getChallengeActivities(since),
|
||||
this.getStrawPollVoteActivities(visibleFriendIds, since),
|
||||
]);
|
||||
|
||||
// Merge and sort by timestamp descending
|
||||
@ -77,6 +78,7 @@ export const feedService = {
|
||||
...spotlights,
|
||||
...referrals,
|
||||
...challenges,
|
||||
...pollVotes,
|
||||
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
// Cap total items
|
||||
@ -362,4 +364,34 @@ export const feedService = {
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async getStrawPollVoteActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
|
||||
const votes = await prisma.strawPollVote.findMany({
|
||||
where: {
|
||||
userId: { in: userIds },
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
poll: { select: { id: true, slug: true, title: true } },
|
||||
option: { select: { label: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
return votes
|
||||
.filter((v) => v.user)
|
||||
.map((v) => ({
|
||||
id: `poll_vote:${v.id}`,
|
||||
type: 'poll_voted' as const,
|
||||
userId: v.user!.id,
|
||||
userName: v.user!.name,
|
||||
userEmail: v.user!.email,
|
||||
title: `Voted on "${v.poll.title}"`,
|
||||
description: `Chose: ${v.option.label}`,
|
||||
metadata: { pollId: v.poll.id, pollSlug: v.poll.slug },
|
||||
timestamp: v.createdAt,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
@ -31,6 +31,18 @@ router.get('/campaigns/:campaignId/friends', async (req: Request, res: Response)
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/integration/straw-polls/:pollId/friends */
|
||||
router.get('/straw-polls/:pollId/friends', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const userId = req.user!.id;
|
||||
const pollId = req.params.pollId as string;
|
||||
const result = await integrationService.getFriendsInStrawPoll(userId, pollId);
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
res.status(err.statusCode || 500).json({ error: { message: err.message } });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/social/integration/map/friends */
|
||||
router.get('/map/friends', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@ -99,6 +99,38 @@ export const integrationService = {
|
||||
};
|
||||
},
|
||||
|
||||
/** Get friends who voted on a straw poll */
|
||||
async getFriendsInStrawPoll(userId: string, pollId: string) {
|
||||
const friendIds = await friendshipService.getFriendIds(userId);
|
||||
if (friendIds.length === 0) return { friends: [], count: 0 };
|
||||
|
||||
const hiddenIds = await this.getHiddenActivityUserIds(friendIds);
|
||||
const visibleIds = friendIds.filter((id) => !hiddenIds.has(id));
|
||||
if (visibleIds.length === 0) return { friends: [], count: 0 };
|
||||
|
||||
const votes = await prisma.strawPollVote.findMany({
|
||||
where: {
|
||||
pollId,
|
||||
userId: { in: visibleIds },
|
||||
},
|
||||
distinct: ['userId'],
|
||||
include: {
|
||||
user: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
friends: votes
|
||||
.filter((v) => v.user)
|
||||
.map((v) => ({
|
||||
id: v.user!.id,
|
||||
name: v.user!.name,
|
||||
email: v.user!.email,
|
||||
})),
|
||||
count: votes.filter((v) => v.user).length,
|
||||
};
|
||||
},
|
||||
|
||||
/** Helper: get user IDs that have showInFriendActivity disabled */
|
||||
async getHiddenActivityUserIds(userIds: string[]): Promise<Set<string>> {
|
||||
const hidden = await prisma.privacySettings.findMany({
|
||||
|
||||
@ -25,6 +25,10 @@ const TYPE_TO_PREF: Record<string, string> = {
|
||||
shift_cancelled: 'enableSystemUpdates',
|
||||
canvass_session_summary: 'enableSystemUpdates',
|
||||
reengagement: 'enableSystemUpdates',
|
||||
// Straw poll notification types
|
||||
poll_closed: 'enableSystemUpdates',
|
||||
poll_results_available: 'enableSystemUpdates',
|
||||
poll_challenge: 'enableFriendRequests',
|
||||
};
|
||||
|
||||
export const notificationService = {
|
||||
|
||||
129
api/src/services/poll-auto-close-queue.service.ts
Normal file
129
api/src/services/poll-auto-close-queue.service.ts
Normal file
@ -0,0 +1,129 @@
|
||||
import { Queue, Worker, type Job } from 'bullmq';
|
||||
import { env } from '../config/env';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
interface PollAutoCloseJobData {
|
||||
pollId: string;
|
||||
}
|
||||
|
||||
class PollAutoCloseQueueService {
|
||||
private queue: Queue;
|
||||
private worker: Worker | null = null;
|
||||
|
||||
constructor() {
|
||||
this.queue = new Queue('straw-poll-auto-close', {
|
||||
connection: { url: env.REDIS_URL },
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 5000 },
|
||||
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 500 },
|
||||
removeOnFail: { age: 30 * 24 * 60 * 60 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
startWorker() {
|
||||
this.worker = new Worker(
|
||||
'straw-poll-auto-close',
|
||||
async (job: Job<PollAutoCloseJobData>) => {
|
||||
const { pollId } = job.data;
|
||||
logger.info(`Processing straw poll auto-close job ${job.id}`, { pollId });
|
||||
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { strawPollsService } = await import(
|
||||
'../modules/polls/polls.service'
|
||||
);
|
||||
await strawPollsService.closePoll(pollId);
|
||||
},
|
||||
{
|
||||
connection: { url: env.REDIS_URL },
|
||||
concurrency: 1,
|
||||
}
|
||||
);
|
||||
|
||||
this.worker.on('completed', (job) => {
|
||||
logger.info(`Straw poll auto-close job ${job.id} completed`);
|
||||
});
|
||||
|
||||
this.worker.on('failed', (job, err) => {
|
||||
logger.error(`Straw poll auto-close job ${job?.id} failed: ${err.message}`);
|
||||
});
|
||||
|
||||
logger.info('Straw poll auto-close queue worker started');
|
||||
|
||||
this.recoverOnStartup().catch((err) =>
|
||||
logger.error('Straw poll auto-close startup recovery failed', { error: err })
|
||||
);
|
||||
}
|
||||
|
||||
async scheduleJob(pollId: string, deadline: Date): Promise<string | null> {
|
||||
const delay = deadline.getTime() - Date.now();
|
||||
if (delay <= 0) {
|
||||
const job = await this.queue.add(`close-${pollId}`, { pollId }, {
|
||||
jobId: `poll-close-${pollId}`,
|
||||
});
|
||||
return job.id ?? null;
|
||||
}
|
||||
|
||||
const job = await this.queue.add(`close-${pollId}`, { pollId }, {
|
||||
delay,
|
||||
jobId: `poll-close-${pollId}`,
|
||||
});
|
||||
logger.info(`Scheduled straw poll auto-close for ${deadline.toISOString()}`, {
|
||||
pollId,
|
||||
jobId: job.id,
|
||||
delayMs: delay,
|
||||
});
|
||||
return job.id ?? null;
|
||||
}
|
||||
|
||||
async cancelJob(pollId: string): Promise<void> {
|
||||
try {
|
||||
const jobs = await this.queue.getJobs(['delayed', 'waiting']);
|
||||
for (const job of jobs) {
|
||||
if (job.data.pollId === pollId) {
|
||||
await job.remove();
|
||||
logger.info(`Cancelled auto-close job for straw poll ${pollId}`, { jobId: job.id });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel straw poll auto-close job', { error, pollId });
|
||||
}
|
||||
}
|
||||
|
||||
private async recoverOnStartup() {
|
||||
const activePolls = await prisma.strawPoll.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
closesAt: { not: null },
|
||||
},
|
||||
select: { id: true, closesAt: true },
|
||||
});
|
||||
|
||||
for (const poll of activePolls) {
|
||||
if (!poll.closesAt) continue;
|
||||
const jobId = await this.scheduleJob(poll.id, poll.closesAt);
|
||||
if (jobId) {
|
||||
await prisma.strawPoll.update({
|
||||
where: { id: poll.id },
|
||||
data: { autoCloseJobId: jobId },
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (activePolls.length > 0) {
|
||||
logger.info(`Recovered ${activePolls.length} straw poll auto-close jobs on startup`);
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.worker) {
|
||||
await this.worker.close();
|
||||
}
|
||||
await this.queue.close();
|
||||
logger.info('Straw poll auto-close queue closed');
|
||||
}
|
||||
}
|
||||
|
||||
export const pollAutoCloseQueueService = new PollAutoCloseQueueService();
|
||||
@ -10,6 +10,7 @@ const ROLE_PRIORITY: Record<string, number> = {
|
||||
PAYMENTS_ADMIN: 4,
|
||||
EVENTS_ADMIN: 4,
|
||||
SOCIAL_ADMIN: 4,
|
||||
POLLS_ADMIN: 4,
|
||||
USER: 2,
|
||||
TEMP: 1,
|
||||
};
|
||||
@ -25,6 +26,7 @@ export const ADMIN_ROLES: UserRole[] = [
|
||||
UserRole.PAYMENTS_ADMIN,
|
||||
UserRole.EVENTS_ADMIN,
|
||||
UserRole.SOCIAL_ADMIN,
|
||||
UserRole.POLLS_ADMIN,
|
||||
];
|
||||
|
||||
// Module-specific role groups
|
||||
@ -38,6 +40,7 @@ export const EVENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.EVENTS_A
|
||||
export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN];
|
||||
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
|
||||
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
|
||||
export const POLLS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.POLLS_ADMIN, UserRole.INFLUENCE_ADMIN];
|
||||
|
||||
/** Check if the user has any of the specified roles */
|
||||
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {
|
||||
|
||||
226
mkdocs/docs/assets/js/straw-poll-widget.js
Normal file
226
mkdocs/docs/assets/js/straw-poll-widget.js
Normal 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 →</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 += ' · ' + 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;">✓ 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); });
|
||||
}
|
||||
})();
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user