Compare commits

...

10 Commits

Author SHA1 Message Date
e95bc8883e scheduling features 2026-03-01 15:22:27 -07:00
aaba7df97d Add new conversation feature to SMS module
Enable starting ad-hoc SMS conversations from the conversations page
by searching contacts across SMS lists, CRM, and existing threads,
then composing and sending a first message.

Bunker Admin
2026-02-28 16:55:24 -07:00
d835f0837b Redesign SMS Contacts page to contacts-first view with cross-list search
Add getAllEntries API endpoint to query individual contacts across all lists
with optional list filter and case-insensitive search. Redesign the frontend
from a lists-only table to a contacts-first layout with search, list filter
dropdown, and a collapsible lists management panel.

Bunker Admin
2026-02-28 16:48:01 -07:00
d98488c1dc Fix people graph to include all source types and use grid layout for disconnected nodes
The graph view only showed managed Contacts and Users (5 nodes) while
the table/cards views showed all 94 people. Added SMS contacts, address
occupants, campaign senders, shift signups, and donations to the graph
API with email/phone deduplication. Updated the frontend layout to
arrange disconnected nodes in a grid instead of a single horizontal
line, while preserving dagre tree layout for connected components.

Bunker Admin
2026-02-28 16:09:12 -07:00
41d86782b4 Move entity results above actions in command palette and tighten fuzzy matching
- Reorder result hierarchy: Pages → Entities → Actions → Settings
  (doc files and database records now appear right after page matches)
- Disable fuzzy matching for terms under 5 characters to prevent
  false positives like "test" matching "text" (all SMS pages)
- Prefix matching still works for short terms (e.g. "mail" → MailHog)

Bunker Admin
2026-02-28 09:22:29 -07:00
46fc92fab8 Add command palette improvements: descriptions, favorites, scope filters, contextual boost, and information hierarchy
- Add description field to CommandItem and descriptions to all 59 registry entries
- Add SMS Templates navigation and quick action entries
- Integrate favorites store: show starred items above recents when no query, star badges in search results
- Add @prefix: scope filtering (10 scopes) with parseQuery(), scope chip UI, and clickable scope list
- Add contextual command boosting based on current route (+50 path match, +25 group match)
- Reorder result hierarchy: Pages → Actions → Entities → Settings
- Extract CommandGroupSection and EntityGroupSection render helpers

Bunker Admin
2026-02-28 09:04:24 -07:00
1f2ce681a6 Add "free*" asterisk modal to landing page hero for transparency
Discloses external production dependencies (server, ISP, domain, tunnel,
SMTP, Android phone for SMS) and offers paid hardware/managed options.

Bunker Admin
2026-02-28 09:04:11 -07:00
98acd4917d Add custom 404 pages and error report email to admins
- NotFoundPage component with Go Back, Go Home (role-aware), and Report to Admin buttons
- Catch-all routes inside AppLayout, VolunteerLayout, and top-level PublicLayout
- POST /api/public/error-report endpoint sends 404 notification emails to super admins
- Express API 404 handler returns consistent JSON error envelope for /api/* routes
- Fastify media API 404 handler via setNotFoundHandler
- Rate-limited error reports (5/hour per IP)

Bunker Admin
2026-02-28 09:04:09 -07:00
18997da3eb Add ~50 missing env vars to CCP env.hbs template for full feature coverage
New instances provisioned via CCP were missing env vars for video analytics,
geocoding config, Listmonk SMTP, Gitea comments, Overpass/area import,
monitoring ports, Bunker Ops, and other features added since the template
was last updated.

Bunker Admin
2026-02-27 20:15:13 -07:00
ce590ccae8 Add missing pages to command palette and fix feature flag handling
Add Social Dashboard/Graph/Moderation, Jitsi Video Meetings, and Ad Analytics
to searchable command registry. Add missing ContactsOutlined and ScissorOutlined
icons to ICON_MAP. Update defaults-off feature flag list to include enableSocial,
enableChat, and enableMeet.

Bunker Admin
2026-02-27 20:11:06 -07:00
62 changed files with 6959 additions and 355 deletions

View File

@ -329,13 +329,15 @@ ALERTMANAGER_EMBED_PORT=8895
# --- SMS Campaigns (Termux Android Bridge) ---
# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative
# URL + API key are typically managed via admin Settings page (DB overrides env)
# Use Tailscale IP (100.x.x.x) for stable addressing across networks
ENABLE_SMS=false
TERMUX_API_URL=http://10.0.0.193:5001
TERMUX_API_URL=http://100.x.x.x:5001
TERMUX_API_KEY=
SMS_DELAY_BETWEEN_MS=3000
SMS_MAX_RETRIES=3
SMS_RESPONSE_SYNC_INTERVAL_MS=30000
SMS_DEVICE_MONITOR_INTERVAL_MS=30000
SMS_RESPONSE_SYNC_INTERVAL_MS=120000
SMS_DEVICE_MONITOR_INTERVAL_MS=300000
# --- Monitoring (only used with --profile monitoring) ---
PROMETHEUS_PORT=9090

View File

@ -14,6 +14,34 @@
</head>
<body style="margin:0;background:#1a1025">
<div id="root"></div>
<noscript>
<div style="display:flex;align-items:center;justify-content:center;height:100vh;color:#e0e0e0;font-family:sans-serif">
<p>JavaScript is required to run this application.</p>
</div>
</noscript>
<script>
// Fallback: if the main module fails to load entirely (stale deployment),
// show a reload prompt after a timeout. The main app replaces #root content on success.
setTimeout(function() {
var root = document.getElementById('root');
if (root && root.children.length === 0) {
var key = 'cm_chunk_reload';
var last = sessionStorage.getItem(key);
var now = Date.now();
if (!last || now - parseInt(last, 10) > 10000) {
sessionStorage.setItem(key, String(now));
window.location.reload();
} else {
root.innerHTML = '<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;height:100vh;color:#e0e0e0;font-family:sans-serif;text-align:center;padding:0 24px">'
+ '<div style="font-size:48px;margin-bottom:16px">&#x21BB;</div>'
+ '<h2 style="margin:0 0 8px;font-size:20px">Application Updated</h2>'
+ '<p style="margin:0 0 24px;color:#999;max-width:400px;line-height:1.5">A new version has been deployed. Please refresh to load the latest version.</p>'
+ '<button onclick="window.location.reload()" style="padding:10px 24px;font-size:14px;border:none;border-radius:6px;background:#9d4edd;color:#fff;cursor:pointer">Refresh Page</button>'
+ '</div>';
}
}
}, 5000);
</script>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -107,6 +107,7 @@ import SmsDashboardPage from '@/pages/sms/SmsDashboardPage';
import SmsContactsPage from '@/pages/sms/SmsContactsPage';
import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage';
import SmsConversationsPage from '@/pages/sms/SmsConversationsPage';
import SmsTemplatesPage from '@/pages/sms/SmsTemplatesPage';
import SmsSetupPage from '@/pages/sms/SmsSetupPage';
import PeoplePage from '@/pages/PeoplePage';
import ContactProfilePage from '@/pages/public/ContactProfilePage';
@ -114,7 +115,11 @@ import SocialDashboardPage from '@/pages/social/SocialDashboardPage';
import SocialGraphPage from '@/pages/social/SocialGraphPage';
import SocialModerationPage from '@/pages/social/SocialModerationPage';
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
import MeetingPlannerPage from '@/pages/MeetingPlannerPage';
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage';
import NotFoundPage from '@/pages/NotFoundPage';
import CommandPalette from '@/components/command-palette/CommandPalette';
function RoleAwareRedirect() {
@ -223,6 +228,14 @@ export default function App() {
<Route path="/events" element={<FeatureGate feature="enableEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<EventsPage />} />
</Route>
{/* Scheduling polls — feature-gated */}
<Route path="/polls" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}>
<Route index element={<PollsListPage />} />
</Route>
<Route path="/poll/:slug" element={<FeatureGate feature="enableMeetingPlanner"><PublicLayout /></FeatureGate>}>
<Route index element={<SchedulingPollPage />} />
</Route>
{/* Public meeting join page — feature-gated */}
<Route path="/meet/:slug" element={<FeatureGate feature="enableMeet"><PublicLayout /></FeatureGate>}>
<Route index element={<MeetingJoinPage />} />
@ -306,6 +319,7 @@ export default function App() {
<Route path="/volunteer/groups/:id" element={<GroupDetailPage />} />
<Route path="/volunteer/achievements" element={<AchievementsPage />} />
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
<Route path="/volunteer/*" element={<NotFoundPage />} />
</Route>
{/* Redirect old canvass routes to map with query param */}
@ -615,6 +629,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="sms/templates"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<SmsTemplatesPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={
@ -663,6 +685,14 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="meeting-planner"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<MeetingPlannerPage />
</ProtectedRoute>
}
/>
<Route
path="map/cuts"
element={
@ -807,8 +837,12 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="/" element={<RoleAwareRedirect />} />
<Route path="*" element={<PublicLayout />}>
<Route path="*" element={<NotFoundPage />} />
</Route>
<Route path="*" element={<RoleAwareRedirect />} />
</Routes>
</BrowserRouter>
</AntApp>

View File

@ -112,6 +112,7 @@ const DEFAULT_ADMIN_NAV_ITEMS: NavItem[] = [
{ id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' },
{ id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'polls', label: 'Polls', path: '/polls', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMeetingPlanner' },
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' },
{ id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' },
{ id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' },
@ -212,6 +213,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/sms/contacts', icon: <TeamOutlined />, label: 'SMS Contacts' },
{ key: '/app/sms/campaigns', icon: <SendOutlined />, label: 'SMS Campaigns' },
{ key: '/app/sms/conversations', icon: <MessageOutlined />, label: 'SMS Threads' },
{ key: '/app/sms/templates', icon: <FileTextOutlined />, label: 'SMS Templates' },
);
}
items.push({
@ -248,7 +250,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
children: [
{ key: '/app/map', icon: <EnvironmentOutlined />, label: 'Locations' },
{ key: '/app/map/data-quality', icon: <BarChartOutlined />, label: 'Data Quality' },
{ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' },
{ key: '/app/map/cuts', icon: <ScissorOutlined />, label: 'Areas' },
{ key: '/app/map/canvass', icon: <TeamOutlined />, label: 'Canvassing' },
{ key: '/app/map/settings', icon: <SettingOutlined />, label: 'Settings' },
@ -256,6 +257,25 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
});
}
// Scheduling submenu — visible if either Shifts (enableMap) or Meeting Planner is enabled
if (settings?.enableMap !== false || settings?.enableMeetingPlanner) {
const schedulingChildren: any[] = [];
if (settings?.enableMap !== false) {
schedulingChildren.push({ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' });
}
if (settings?.enableMeetingPlanner) {
schedulingChildren.push({ key: '/app/meeting-planner', icon: <CalendarOutlined />, label: 'Meeting Planner' });
}
if (schedulingChildren.length > 0) {
items.push({
key: 'scheduling-submenu',
icon: <ScheduleOutlined />,
label: 'Scheduling',
children: schedulingChildren,
});
}
}
if (settings?.enableMediaFeatures !== false) {
items.push({
key: 'media-submenu',

View File

@ -0,0 +1,113 @@
import { Component, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
isChunkError: boolean;
}
/**
* Global error boundary that catches React rendering crashes.
* Detects stale chunk errors (after redeployment) and offers auto-reload.
*/
export default class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, isChunkError: false };
static getDerivedStateFromError(error: Error): State {
const isChunkError = isChunkLoadError(error);
return { hasError: true, isChunkError };
}
componentDidCatch(error: Error) {
// If it's a stale chunk error, auto-reload once (avoid infinite loop via sessionStorage flag)
if (isChunkLoadError(error)) {
const reloadKey = 'cm_chunk_reload';
const lastReload = sessionStorage.getItem(reloadKey);
const now = Date.now();
// Only auto-reload if we haven't done so in the last 10 seconds
if (!lastReload || now - parseInt(lastReload, 10) > 10000) {
sessionStorage.setItem(reloadKey, String(now));
window.location.reload();
return;
}
}
}
handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: '#1a1025',
color: '#e0e0e0',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
textAlign: 'center',
padding: '0 24px',
}}
>
<div style={{ fontSize: 48, marginBottom: 16 }}>
{this.state.isChunkError ? '\u21BB' : '\u26A0'}
</div>
<h2 style={{ margin: '0 0 8px', fontSize: 20, fontWeight: 600 }}>
{this.state.isChunkError
? 'Application Updated'
: 'Something went wrong'}
</h2>
<p style={{ margin: '0 0 24px', color: '#999', maxWidth: 400, lineHeight: 1.5 }}>
{this.state.isChunkError
? 'A new version has been deployed. Please refresh to load the latest version.'
: 'An unexpected error occurred. A page refresh usually fixes this.'}
</p>
<button
onClick={this.handleReload}
style={{
padding: '10px 24px',
fontSize: 14,
fontWeight: 500,
border: 'none',
borderRadius: 6,
background: '#9d4edd',
color: '#fff',
cursor: 'pointer',
transition: 'opacity 0.2s',
}}
onMouseOver={(e) => (e.currentTarget.style.opacity = '0.85')}
onMouseOut={(e) => (e.currentTarget.style.opacity = '1')}
>
Refresh Page
</button>
</div>
);
}
return this.props.children;
}
}
/** Detect chunk/module load errors (stale deployments) */
function isChunkLoadError(error: Error): boolean {
const msg = error.message || '';
return (
msg.includes('Failed to fetch dynamically imported module') ||
msg.includes('Loading chunk') ||
msg.includes('Loading CSS chunk') ||
msg.includes('error loading dynamically imported module') ||
msg.includes('Importing a module script failed') ||
// Vite-specific: chunk not found
msg.includes('is not a valid JavaScript MIME type') ||
// Generic syntax errors from loading wrong content (e.g., HTML 404 page as JS)
(error.name === 'SyntaxError' && msg.includes('Unexpected token'))
);
}

View File

@ -19,10 +19,11 @@ const FEATURE_LABELS: Record<string, string> = {
enableEvents: 'Events',
enableSocial: 'Social Connections',
enableMeet: 'Video Meetings',
enableMeetingPlanner: 'Meeting Planner',
};
interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet'>;
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner'>;
children: ReactNode;
}

View File

@ -549,6 +549,28 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
</div>
</section>`;
}
case 'scheduling-poll': {
const pollSlug = (defaults.pollSlug as string) || '';
const showComments = defaults.showComments !== false;
const title = (defaults.title as string) || 'Vote on a Meeting Time';
return `
<section style="padding: 60px 40px;">
<div class="scheduling-poll-block"
data-poll-slug="${pollSlug}"
data-show-comments="${showComments}"
data-title="${title}"
style="max-width: 700px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #fa8c16 0%, #d46b08 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="M880 184H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v664c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V216c0-17.7-14.3-32-32-32z"/>
</svg>
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">${title}</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;">Poll will render on published page</p>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}

View File

@ -153,10 +153,11 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3, padding: '0 2px' }}>
{visible.map(item => {
const isPoll = item.type === 'poll';
const isShift = item.type === 'shift';
const bg = isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
const border = isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
const accent = isShift ? '#1890ff' : '#52c41a';
const bg = isPoll ? 'rgba(250, 140, 22, 0.2)' : isShift ? 'rgba(24, 144, 255, 0.2)' : 'rgba(82, 196, 26, 0.2)';
const border = isPoll ? 'rgba(250, 140, 22, 0.5)' : isShift ? 'rgba(24, 144, 255, 0.5)' : 'rgba(82, 196, 26, 0.5)';
const accent = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
return (
<div
key={item.id}
@ -195,6 +196,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
const renderItemCard = (item: UnifiedCalendarItem) => {
const isShift = item.type === 'shift';
const isPoll = item.type === 'poll';
const spotsLeft = isShift && item.maxVolunteers
? item.maxVolunteers - (item.currentVolunteers || 0)
: null;
@ -202,13 +204,16 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
const pct = isShift && item.maxVolunteers && item.maxVolunteers > 0
? Math.round(((item.currentVolunteers || 0) / item.maxVolunteers) * 100)
: 0;
const borderColor = isPoll ? '#fa8c16' : isShift ? '#1890ff' : '#52c41a';
const tagColor = isPoll ? 'orange' : isShift ? 'blue' : 'green';
const tagLabel = isPoll ? 'Poll' : isShift ? 'Shift' : 'Event';
return (
<Card
key={item.id}
size="small"
style={{
borderLeft: `4px solid ${isShift ? '#1890ff' : '#52c41a'}`,
borderLeft: `4px solid ${borderColor}`,
marginBottom: 8,
}}
>
@ -221,8 +226,8 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
</Tooltip>
)}
</Text>
<Tag color={isShift ? 'blue' : 'green'} style={{ margin: 0, fontSize: 11 }}>
{isShift ? 'Shift' : 'Event'}
<Tag color={tagColor} style={{ margin: 0, fontSize: 11 }}>
{tagLabel}
</Tag>
</div>
@ -286,7 +291,7 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
</Button>
)}
{!isShift && item.gancioUrl && (
{!isShift && !isPoll && item.gancioUrl && (
<Button
type="link"
size="small"
@ -298,6 +303,19 @@ export default function UnifiedCalendar({ onShiftSignup, gancioUrl, onEventSubmi
View
</Button>
)}
{isPoll && item.pollSlug && (
<Button
type="primary"
size="small"
icon={<CalendarOutlined />}
href={`/poll/${item.pollSlug}`}
target="_blank"
rel="noopener noreferrer"
>
Vote ({item.pollVoteCount ?? 0})
</Button>
)}
</Space>
</Card>
);

View File

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Modal, Typography, Grid, Spin, theme, type GlobalToken } from 'antd';
import { Modal, Typography, Grid, Spin, Tag, theme, type GlobalToken } from 'antd';
import {
SearchOutlined,
DashboardOutlined,
@ -40,11 +40,16 @@ import {
ThunderboltOutlined,
UserOutlined,
FileMarkdownOutlined,
ContactsOutlined,
ScissorOutlined,
StarFilled,
} from '@ant-design/icons';
import { useCommandPaletteStore } from '@/stores/command-palette.store';
import { useAuthStore } from '@/stores/auth.store';
import { useFavoritesStore } from '@/stores/favorites.store';
import { useCommandIndex } from './useCommandIndex';
import { useEntitySearch } from './useEntitySearch';
import { parseQuery, SCOPE_DEFINITIONS } from './scopeFilter';
import type { CommandItem, EntityResult, CommandCategory } from './types';
const { Text } = Typography;
@ -88,6 +93,8 @@ const ICON_MAP: Record<string, React.ReactNode> = {
TagOutlined: <TagOutlined />,
FileMarkdownOutlined: <FileMarkdownOutlined />,
UserOutlined: <UserOutlined />,
ContactsOutlined: <ContactsOutlined />,
ScissorOutlined: <ScissorOutlined />,
};
const CATEGORY_LABELS: Record<CommandCategory, string> = {
@ -104,6 +111,7 @@ type FlatItem =
export default function CommandPalette() {
const { isOpen, close, recentItems, addRecent } = useCommandPaletteStore();
const { isAuthenticated } = useAuthStore();
const { favorites } = useFavoritesStore();
const location = useLocation();
const navigate = useNavigate();
const { token } = theme.useToken();
@ -115,8 +123,14 @@ export default function CommandPalette() {
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
// Parse query for scope prefix
const parsed = useMemo(() => parseQuery(query), [query]);
const { search, allItems } = useCommandIndex();
const { results: entityResults, loading: entityLoading } = useEntitySearch(query);
const { results: entityResults, loading: entityLoading } = useEntitySearch(
parsed.strippedQuery,
parsed.scope?.entityTypes,
);
// Only render inside admin panel
const isAdminRoute = location.pathname.startsWith('/app');
@ -152,37 +166,61 @@ export default function CommandPalette() {
// Build command results from search
const commandResults = useMemo(() => {
if (!query) return [];
return search(query);
}, [query, search]);
if (!parsed.strippedQuery && !parsed.scope) return [];
// If scope is active but query is empty, show all items in that scope's groups
if (parsed.scope && !parsed.strippedQuery) {
return allItems.filter((item) => parsed.scope!.groups.includes(item.group));
}
return search(parsed.strippedQuery, parsed.scope?.groups);
}, [parsed, search, allItems]);
// Build recent items list (when no query)
// Build favorite items (when no query)
const favoriteCommandItems = useMemo(() => {
if (query) return [];
const favoriteSet = new Set(favorites);
return allItems.filter((item) => favoriteSet.has(item.path));
}, [query, favorites, allItems]);
// Set of favorited paths for badge rendering
const favoritePaths = useMemo(() => new Set(favorites), [favorites]);
// Build recent items list (when no query), excluding favorites
const recentCommandItems = useMemo(() => {
if (query) return [];
const favIds = new Set(favoriteCommandItems.map((i) => i.id));
return recentItems
.map((id) => allItems.find((item) => item.id === id))
.filter((item): item is CommandItem => !!item);
}, [query, recentItems, allItems]);
.filter((item): item is CommandItem => !!item && !favIds.has(item.id));
}, [query, recentItems, allItems, favoriteCommandItems]);
// Flatten all results into a single list for keyboard navigation
// Flatten all results into a single list for keyboard navigation.
// Order: Favorites → Recent → Pages → Entities → Actions → Settings
const flatList = useMemo((): FlatItem[] => {
const items: FlatItem[] = [];
if (!query) {
// Show recents
for (const item of favoriteCommandItems) {
items.push({ type: 'command', item });
}
for (const item of recentCommandItems) {
items.push({ type: 'command', item });
}
} else if (parsed.showScopeList) {
// No items when showing scope selector
} else {
// Show search results
for (const item of commandResults) {
items.push({ type: 'command', item });
}
for (const item of entityResults) {
items.push({ type: 'entity', item });
}
const pages = commandResults.filter((i) => i.category === 'navigation');
const actions = commandResults.filter((i) => i.category === 'action');
const settings = commandResults.filter((i) => i.category === 'settings');
// Pages first (exact name matches ranked highest by MiniSearch)
for (const item of pages) items.push({ type: 'command', item });
// Entities next (database records like doc files, campaigns, users)
for (const item of entityResults) items.push({ type: 'entity', item });
// Actions after entities
for (const item of actions) items.push({ type: 'command', item });
// Settings last
for (const item of settings) items.push({ type: 'command', item });
}
return items;
}, [query, recentCommandItems, commandResults, entityResults]);
}, [query, favoriteCommandItems, recentCommandItems, parsed.showScopeList, commandResults, entityResults]);
// Clamp selected index
useEffect(() => {
@ -229,19 +267,40 @@ export default function CommandPalette() {
[flatList, selectedIndex, handleSelect],
);
if (!shouldRender) return null;
// Group commands by category for display, in priority order:
// Pages (navigation) → Actions → Settings (least frequent)
// Entities are interleaved between pages and actions in the render phase.
// NOTE: These hooks must stay above the early return to satisfy Rules of Hooks
const CATEGORY_ORDER: (CommandCategory | 'favorites' | 'recent')[] = [
'favorites', 'recent', 'navigation', 'action', 'settings',
];
// Group commands by category for display
const groupedCommands = useMemo(() => {
const items = query ? commandResults : recentCommandItems;
const groups = new Map<string, CommandItem[]>();
for (const item of items) {
const key = query ? item.category : 'recent';
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
const unordered = new Map<string, CommandItem[]>();
if (!query) {
// No-query mode: favorites then recents
if (favoriteCommandItems.length > 0) {
unordered.set('favorites', favoriteCommandItems);
}
return groups;
}, [query, commandResults, recentCommandItems]);
if (recentCommandItems.length > 0) {
unordered.set('recent', recentCommandItems);
}
} else if (!parsed.showScopeList) {
for (const item of commandResults) {
const key = item.category;
if (!unordered.has(key)) unordered.set(key, []);
unordered.get(key)!.push(item);
}
}
// Re-insert in priority order
const ordered = new Map<string, CommandItem[]>();
for (const key of CATEGORY_ORDER) {
if (unordered.has(key)) ordered.set(key, unordered.get(key)!);
}
return ordered;
}, [query, parsed.showScopeList, commandResults, favoriteCommandItems, recentCommandItems]);
// Group entities by type
const groupedEntities = useMemo(() => {
@ -253,11 +312,18 @@ export default function CommandPalette() {
return groups;
}, [entityResults]);
if (!shouldRender) return null;
// Get flat index for a given item
let flatIndex = 0;
const isMac = navigator.platform?.toLowerCase().includes('mac');
// Dynamic placeholder
const placeholder = parsed.scope
? `Search ${parsed.scope.label.toLowerCase()}...`
: 'Search pages, settings, data...';
return (
<Modal
open={isOpen}
@ -290,6 +356,16 @@ export default function CommandPalette() {
}}
>
<SearchOutlined style={{ color: token.colorTextSecondary, fontSize: 16 }} />
{parsed.scope && (
<Tag
color={token.colorPrimary}
style={{ margin: 0, fontSize: 11, lineHeight: '18px', padding: '0 6px' }}
closable
onClose={() => setQuery(parsed.strippedQuery)}
>
{parsed.scope.label}
</Tag>
)}
<input
ref={inputRef}
value={query}
@ -297,7 +373,7 @@ export default function CommandPalette() {
setQuery(e.target.value);
setSelectedIndex(0);
}}
placeholder="Search pages, settings, data..."
placeholder={placeholder}
style={{
flex: 1,
border: 'none',
@ -320,9 +396,9 @@ export default function CommandPalette() {
padding: '4px 0',
}}
>
{/* Command groups */}
{Array.from(groupedCommands.entries()).map(([groupKey, items]) => (
<div key={groupKey}>
{/* Scope selector (when user types bare @) */}
{parsed.showScopeList && (
<div>
<div
style={{
padding: '8px 16px 4px',
@ -333,85 +409,143 @@ export default function CommandPalette() {
color: token.colorTextSecondary,
}}
>
{groupKey === 'recent'
? 'Recent'
: CATEGORY_LABELS[groupKey as CommandCategory] ?? groupKey}
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
Filter by scope
</div>
{items.map((item) => {
const idx = flatIndex++;
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
badge={item.category === 'action' ? <ThunderboltOutlined style={{ fontSize: 10, color: token.colorWarning }} /> : null}
subtitle={item.group}
token={token}
onSelect={() => handleSelect({ type: 'command', item })}
onHover={() => setSelectedIndex(idx)}
/>
);
})}
</div>
))}
{/* Entity groups */}
{query &&
Array.from(groupedEntities.entries()).map(([entityType, items]) => (
<div key={entityType}>
{SCOPE_DEFINITIONS.map((scope) => (
<div
key={scope.prefix}
onClick={() => {
setQuery(`@${scope.prefix}:`);
setSelectedIndex(0);
inputRef.current?.focus();
}}
style={{
padding: '8px 16px 4px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: token.colorTextSecondary,
padding: '8px 16px',
display: 'flex',
alignItems: 'center',
gap: 10,
cursor: 'pointer',
borderRadius: 4,
margin: '0 4px',
transition: 'background 0.1s',
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLDivElement).style.background = token.colorPrimaryBg;
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLDivElement).style.background = 'transparent';
}}
>
{entityType}s
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
<span
style={{
fontFamily: 'monospace',
fontSize: 13,
color: token.colorPrimary,
width: 90,
flexShrink: 0,
}}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
</div>
{items.map((item) => {
const idx = flatIndex++;
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
subtitle={item.subtitle || item.entityType}
token={token}
onSelect={() => handleSelect({ type: 'entity', item })}
onHover={() => setSelectedIndex(idx)}
/>
);
})}
@{scope.prefix}:
</span>
<span style={{ color: token.colorText, fontSize: 14 }}>{scope.label}</span>
</div>
))}
{/* Empty state */}
{flatList.length === 0 && !entityLoading && query && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">No results for "{query}"</Text>
</div>
)}
{/* No query, no recents */}
{/* Render in priority order: Pages Entities Actions Settings
For no-query mode: Favorites Recent (no entities) */}
{(() => {
const sections: React.ReactNode[] = [];
const commandEntries = Array.from(groupedCommands.entries());
// Pages (navigation) first — skip actions and settings for now
for (const [groupKey, items] of commandEntries) {
if (groupKey === 'settings' || groupKey === 'action') continue;
sections.push(
<CommandGroupSection
key={groupKey}
groupKey={groupKey}
items={items}
flatIndexRef={{ current: flatIndex }}
selectedIndex={selectedIndex}
favoritePaths={favoritePaths}
token={token}
onSelect={handleSelect}
onHover={setSelectedIndex}
/>,
);
flatIndex += items.length;
}
// Entities right after pages (doc files, campaigns, users, etc.)
if (query && !parsed.showScopeList) {
for (const [entityType, items] of groupedEntities.entries()) {
sections.push(
<EntityGroupSection
key={entityType}
entityType={entityType}
items={items}
flatIndexRef={{ current: flatIndex }}
selectedIndex={selectedIndex}
token={token}
onSelect={handleSelect}
onHover={setSelectedIndex}
/>,
);
flatIndex += items.length;
}
}
// Actions after entities
const actionItems = groupedCommands.get('action');
if (actionItems) {
sections.push(
<CommandGroupSection
key="action"
groupKey="action"
items={actionItems}
flatIndexRef={{ current: flatIndex }}
selectedIndex={selectedIndex}
favoritePaths={favoritePaths}
token={token}
onSelect={handleSelect}
onHover={setSelectedIndex}
/>,
);
flatIndex += actionItems.length;
}
// Settings last
const settingsItems = groupedCommands.get('settings');
if (settingsItems) {
sections.push(
<CommandGroupSection
key="settings"
groupKey="settings"
items={settingsItems}
flatIndexRef={{ current: flatIndex }}
selectedIndex={selectedIndex}
favoritePaths={favoritePaths}
token={token}
onSelect={handleSelect}
onHover={setSelectedIndex}
/>,
);
flatIndex += settingsItems.length;
}
return sections;
})()}
{/* Empty state */}
{flatList.length === 0 && !entityLoading && query && !parsed.showScopeList && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">No results for "{parsed.strippedQuery || query}"</Text>
</div>
)}
{/* No query, no recents, no favorites */}
{flatList.length === 0 && !query && (
<div style={{ padding: '24px 16px', textAlign: 'center' }}>
<Text type="secondary">Type to search pages, settings, and data</Text>
@ -428,11 +562,13 @@ export default function CommandPalette() {
gap: 16,
fontSize: 12,
color: token.colorTextTertiary,
flexWrap: 'wrap',
}}
>
<span><kbd style={kbdStyle(token)}></kbd> navigate</span>
<span><kbd style={kbdStyle(token)}></kbd> open</span>
<span><kbd style={kbdStyle(token)}>esc</kbd> close</span>
<span><kbd style={kbdStyle(token)}>@</kbd> scope</span>
<span style={{ marginLeft: 'auto' }}>
<kbd style={kbdStyle(token)}>{isMac ? '⌘' : 'Ctrl'}</kbd>+<kbd style={kbdStyle(token)}>K</kbd>
</span>
@ -454,12 +590,138 @@ function kbdStyle(token: GlobalToken): React.CSSProperties {
};
}
const GROUP_HEADER_STYLE: React.CSSProperties = {
padding: '8px 16px 4px',
fontSize: 11,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.05em',
};
/** Render a group of command items with a header */
function CommandGroupSection({
groupKey,
items,
flatIndexRef,
selectedIndex,
favoritePaths,
token,
onSelect,
onHover,
}: {
groupKey: string;
items: CommandItem[];
flatIndexRef: { current: number };
selectedIndex: number;
favoritePaths: Set<string>;
token: GlobalToken;
onSelect: (item: FlatItem) => void;
onHover: (idx: number) => void;
}) {
const label =
groupKey === 'recent'
? 'Recent'
: groupKey === 'favorites'
? 'Favorites'
: CATEGORY_LABELS[groupKey as CommandCategory] ?? groupKey;
return (
<div>
<div style={{ ...GROUP_HEADER_STYLE, color: token.colorTextSecondary }}>
{label}
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
</div>
{items.map((item) => {
const idx = flatIndexRef.current++;
const isFav = favoritePaths.has(item.path);
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
description={item.description}
badge={
<>
{isFav && <StarFilled style={{ fontSize: 10, color: '#faad14' }} />}
{item.category === 'action' && (
<ThunderboltOutlined style={{ fontSize: 10, color: token.colorWarning }} />
)}
</>
}
subtitle={item.group}
token={token}
onSelect={() => onSelect({ type: 'command', item })}
onHover={() => onHover(idx)}
/>
);
})}
</div>
);
}
/** Render a group of entity results with a header */
function EntityGroupSection({
entityType,
items,
flatIndexRef,
selectedIndex,
token,
onSelect,
onHover,
}: {
entityType: string;
items: EntityResult[];
flatIndexRef: { current: number };
selectedIndex: number;
token: GlobalToken;
onSelect: (item: FlatItem) => void;
onHover: (idx: number) => void;
}) {
return (
<div>
<div style={{ ...GROUP_HEADER_STYLE, color: token.colorTextSecondary }}>
{entityType}s
<Text
type="secondary"
style={{ float: 'right', fontSize: 11, fontWeight: 400, textTransform: 'none', letterSpacing: 0 }}
>
{items.length} {items.length === 1 ? 'result' : 'results'}
</Text>
</div>
{items.map((item) => {
const idx = flatIndexRef.current++;
return (
<ResultRow
key={item.id}
index={idx}
selected={idx === selectedIndex}
icon={ICON_MAP[item.icon ?? ''] ?? <SearchOutlined />}
title={item.title}
subtitle={item.subtitle || item.entityType}
token={token}
onSelect={() => onSelect({ type: 'entity', item })}
onHover={() => onHover(idx)}
/>
);
})}
</div>
);
}
/** Single result row */
function ResultRow({
index,
selected,
icon,
title,
description,
subtitle,
badge,
token,
@ -470,6 +732,7 @@ function ResultRow({
selected: boolean;
icon: React.ReactNode;
title: string;
description?: string;
subtitle?: string;
badge?: React.ReactNode;
token: GlobalToken;
@ -496,9 +759,35 @@ function ResultRow({
<span style={{ fontSize: 16, color: token.colorTextSecondary, flexShrink: 0, width: 20, textAlign: 'center' }}>
{icon}
</span>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: token.colorText, fontSize: 14 }}>
<span style={{ flex: 1, overflow: 'hidden', minWidth: 0 }}>
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorText,
fontSize: 14,
}}
>
{title}
</span>
{description && (
<span
style={{
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
color: token.colorTextTertiary,
fontSize: 12,
lineHeight: '16px',
}}
>
{description}
</span>
)}
</span>
{badge}
{subtitle && (
<Text type="secondary" style={{ fontSize: 12, flexShrink: 0 }}>

View File

@ -12,6 +12,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-dashboard',
title: 'Dashboard',
description: 'Platform overview with key metrics and activity',
group: 'General',
path: '/app',
icon: 'DashboardOutlined',
@ -21,6 +22,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-users',
title: 'Users',
description: 'Manage user accounts, roles, and permissions',
group: 'People & Access',
path: '/app/users',
icon: 'TeamOutlined',
@ -30,6 +32,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-people',
title: 'People',
description: 'CRM contacts directory and engagement tracking',
group: 'People & Access',
path: '/app/people',
icon: 'ContactsOutlined',
@ -41,6 +44,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-settings',
title: 'Settings',
description: 'Global platform configuration and preferences',
group: 'General',
path: '/app/settings',
icon: 'SettingOutlined',
@ -49,10 +53,49 @@ export const commandRegistry: CommandItem[] = [
requiredRoles: ['SUPER_ADMIN'],
},
// ── Navigation: Social ───────────────────────────────
{
id: 'nav-social-dashboard',
title: 'Social Dashboard',
description: 'Community engagement overview and social stats',
group: 'Social',
path: '/app/social',
icon: 'TeamOutlined',
keywords: ['social', 'community', 'friends', 'connections', 'engagement'],
category: 'navigation',
featureFlag: 'enableSocial',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
},
{
id: 'nav-social-graph',
title: 'Social Graph',
description: 'Visualize connections between community members',
group: 'Social',
path: '/app/social/graph',
icon: 'BranchesOutlined',
keywords: ['network', 'connections', 'graph', 'relationships', 'visualization'],
category: 'navigation',
featureFlag: 'enableSocial',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
},
{
id: 'nav-social-moderation',
title: 'Social Moderation',
description: 'Review flagged content and user reports',
group: 'Social',
path: '/app/social/moderation',
icon: 'MessageOutlined',
keywords: ['moderation', 'reports', 'flagged', 'content review', 'social moderation'],
category: 'navigation',
featureFlag: 'enableSocial',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
},
// ── Navigation: Advocacy ──────────────────────────────
{
id: 'nav-campaigns',
title: 'Campaigns',
description: 'Create and manage advocacy email campaigns',
group: 'Advocacy',
path: '/app/campaigns',
icon: 'SendOutlined',
@ -63,6 +106,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-campaign-review',
title: 'Campaign Review',
description: 'Moderate and approve pending campaign submissions',
group: 'Advocacy',
path: '/app/campaign-moderation',
icon: 'FileTextOutlined',
@ -73,6 +117,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-representatives',
title: 'Representatives',
description: 'Browse and manage elected official data cache',
group: 'Advocacy',
path: '/app/representatives',
icon: 'IdcardOutlined',
@ -83,6 +128,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-outgoing-emails',
title: 'Outgoing Emails',
description: 'Monitor the advocacy email sending queue',
group: 'Advocacy',
path: '/app/email-queue',
icon: 'MailOutlined',
@ -93,6 +139,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-responses',
title: 'Responses',
description: 'Moderate public responses and feedback',
group: 'Advocacy',
path: '/app/responses',
icon: 'MessageOutlined',
@ -103,6 +150,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-effectiveness',
title: 'Effectiveness',
description: 'Campaign performance analytics and metrics',
group: 'Advocacy',
path: '/app/influence/effectiveness',
icon: 'LineChartOutlined',
@ -115,6 +163,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-newsletter',
title: 'Newsletter',
description: 'Manage mailing lists and broadcast emails',
group: 'Broadcast',
path: '/app/listmonk',
icon: 'MailOutlined',
@ -126,6 +175,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-email-templates',
title: 'Email Templates',
description: 'Design reusable email templates',
group: 'Broadcast',
path: '/app/email-templates',
icon: 'FileTextOutlined',
@ -136,6 +186,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-setup',
title: 'SMS Setup',
description: 'Configure the Termux SMS bridge device',
group: 'Broadcast',
path: '/app/sms/setup',
icon: 'SettingOutlined',
@ -147,6 +198,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-dashboard',
title: 'SMS Dashboard',
description: 'Text messaging overview and delivery stats',
group: 'Broadcast',
path: '/app/sms',
icon: 'PhoneOutlined',
@ -157,6 +209,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-contacts',
title: 'SMS Contacts',
description: 'Manage contact lists and phone numbers',
group: 'Broadcast',
path: '/app/sms/contacts',
icon: 'TeamOutlined',
@ -167,6 +220,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-campaigns',
title: 'SMS Campaigns',
description: 'Create and send bulk text message campaigns',
group: 'Broadcast',
path: '/app/sms/campaigns',
icon: 'SendOutlined',
@ -177,6 +231,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-sms-conversations',
title: 'SMS Threads',
description: 'View and reply to text message conversations',
group: 'Broadcast',
path: '/app/sms/conversations',
icon: 'MessageOutlined',
@ -184,11 +239,23 @@ export const commandRegistry: CommandItem[] = [
category: 'navigation',
featureFlag: 'enableSms',
},
{
id: 'nav-sms-templates',
title: 'SMS Templates',
description: 'Manage reusable text message templates',
group: 'Broadcast',
path: '/app/sms/templates',
icon: 'FileTextOutlined',
keywords: ['sms template', 'message template', 'canned response'],
category: 'navigation',
featureFlag: 'enableSms',
},
// ── Navigation: Web ───────────────────────────────────
{
id: 'nav-landing-pages',
title: 'Landing Pages',
description: 'Build and manage website landing pages',
group: 'Web',
path: '/app/pages',
icon: 'FileTextOutlined',
@ -199,6 +266,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-navigation',
title: 'Navigation',
description: 'Configure public site header and menu links',
group: 'Web',
path: '/app/navigation',
icon: 'GlobalOutlined',
@ -208,6 +276,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-documentation',
title: 'Documentation',
description: 'Manage MkDocs knowledge base articles',
group: 'Web',
path: '/app/docs',
icon: 'BookOutlined',
@ -217,6 +286,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-docs-analytics',
title: 'Docs Analytics',
description: 'View documentation page views and metrics',
group: 'Web',
path: '/app/docs/analytics',
icon: 'BarChartOutlined',
@ -226,6 +296,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-docs-comments',
title: 'Docs Comments',
description: 'Moderate documentation feedback and discussion',
group: 'Web',
path: '/app/docs/comments',
icon: 'MessageOutlined',
@ -235,6 +306,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-docs-settings',
title: 'Docs Settings',
description: 'Configure MkDocs site and theme options',
group: 'Web',
path: '/app/docs/settings',
icon: 'SettingOutlined',
@ -244,6 +316,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-code-editor',
title: 'Code Editor',
description: 'Open the web-based Code Server IDE',
group: 'Web',
path: '/app/code',
icon: 'CodeOutlined',
@ -256,6 +329,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-locations',
title: 'Locations',
description: 'Manage addresses, geocoding, and CSV imports',
group: 'Map',
path: '/app/map',
icon: 'EnvironmentOutlined',
@ -266,6 +340,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-data-quality',
title: 'Data Quality',
description: 'Geocoding quality metrics and data health',
group: 'Map',
path: '/app/map/data-quality',
icon: 'BarChartOutlined',
@ -276,6 +351,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-shifts',
title: 'Shifts',
description: 'Schedule volunteer shifts and manage signups',
group: 'Map',
path: '/app/map/shifts',
icon: 'ScheduleOutlined',
@ -286,6 +362,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-areas',
title: 'Areas',
description: 'Draw and manage canvassing territory boundaries',
group: 'Map',
path: '/app/map/cuts',
icon: 'ScissorOutlined',
@ -296,6 +373,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-canvassing',
title: 'Canvassing',
description: 'View volunteer sessions, visits, and routes',
group: 'Map',
path: '/app/map/canvass',
icon: 'TeamOutlined',
@ -306,6 +384,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-map-settings',
title: 'Map Settings',
description: 'Configure map center, zoom, and geocoding provider',
group: 'Map',
path: '/app/map/settings',
icon: 'SettingOutlined',
@ -318,6 +397,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-library',
title: 'Library',
description: 'Upload and manage video files',
group: 'Media',
path: '/app/media/library',
icon: 'FolderOutlined',
@ -328,6 +408,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-analytics',
title: 'Media Analytics',
description: 'Video views, watch time, and engagement stats',
group: 'Media',
path: '/app/media/analytics',
icon: 'BarChartOutlined',
@ -338,6 +419,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-curated',
title: 'Curated',
description: 'Manage featured playlists and collections',
group: 'Media',
path: '/app/media/curated',
icon: 'OrderedListOutlined',
@ -348,6 +430,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-media-moderation',
title: 'Media Moderation',
description: 'Review and moderate video comments',
group: 'Media',
path: '/app/media/moderation',
icon: 'MessageOutlined',
@ -358,6 +441,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-gallery-ads',
title: 'Gallery Ads',
description: 'Manage banner advertisements in the gallery',
group: 'Payments',
path: '/app/payments/ads',
icon: 'PictureOutlined',
@ -366,9 +450,22 @@ export const commandRegistry: CommandItem[] = [
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-ad-analytics',
title: 'Ad Analytics',
description: 'Track ad impressions, clicks, and performance',
group: 'Payments',
path: '/app/payments/ads/analytics',
icon: 'LineChartOutlined',
keywords: ['ad performance', 'ad metrics', 'banner stats', 'ad dashboard'],
category: 'navigation',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-media-jobs',
title: 'Processing Jobs',
description: 'Monitor video encoding and processing tasks',
group: 'Media',
path: '/app/media/jobs',
icon: 'HistoryOutlined',
@ -381,6 +478,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-payments-dashboard',
title: 'Payments Dashboard',
description: 'Revenue overview and transaction summary',
group: 'Payments',
path: '/app/payments',
icon: 'DashboardOutlined',
@ -392,6 +490,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-plans',
title: 'Plans',
description: 'Create subscription plans and pricing tiers',
group: 'Payments',
path: '/app/payments/plans',
icon: 'TagOutlined',
@ -403,6 +502,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-subscribers',
title: 'Subscribers',
description: 'View and manage paying subscribers',
group: 'Payments',
path: '/app/payments/subscribers',
icon: 'CrownOutlined',
@ -414,6 +514,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-products',
title: 'Products',
description: 'Manage store products and merchandise',
group: 'Payments',
path: '/app/payments/products',
icon: 'ShoppingOutlined',
@ -425,6 +526,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-donation-pages',
title: 'Donation Pages',
description: 'Create fundraising and donation pages',
group: 'Payments',
path: '/app/payments/donation-pages',
icon: 'HeartOutlined',
@ -436,6 +538,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-donation-orders',
title: 'Donation Orders',
description: 'Track received donations and transaction history',
group: 'Payments',
path: '/app/payments/donations',
icon: 'DollarOutlined',
@ -447,6 +550,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-payment-settings',
title: 'Payment Settings',
description: 'Configure payment gateway and Stripe integration',
group: 'Payments',
path: '/app/payments/settings',
icon: 'SettingOutlined',
@ -460,6 +564,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-tunnel',
title: 'Tunnel',
description: 'Manage Pangolin reverse tunnel for public access',
group: 'Services',
path: '/app/tunnel',
icon: 'CloudServerOutlined',
@ -470,6 +575,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-monitoring',
title: 'Monitoring',
description: 'Prometheus metrics, Grafana dashboards, and alerts',
group: 'Services',
path: '/app/observability',
icon: 'LineChartOutlined',
@ -480,6 +586,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-database',
title: 'Database',
description: 'Browse database tables with NocoDB',
group: 'Services',
path: '/app/services/nocodb',
icon: 'DatabaseOutlined',
@ -490,6 +597,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-vault',
title: 'Vault',
description: 'Vaultwarden password manager for the team',
group: 'Services',
path: '/app/services/vaultwarden',
icon: 'LockOutlined',
@ -500,6 +608,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-mailhog',
title: 'MailHog',
description: 'Capture and inspect test emails in development',
group: 'Services',
path: '/app/services/mailhog',
icon: 'MailOutlined',
@ -510,6 +619,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-workflows',
title: 'Workflows',
description: 'n8n workflow automation and integrations',
group: 'Services',
path: '/app/services/n8n',
icon: 'ApiOutlined',
@ -520,6 +630,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-git',
title: 'Git',
description: 'Gitea self-hosted Git repositories',
group: 'Services',
path: '/app/services/gitea',
icon: 'BranchesOutlined',
@ -530,6 +641,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-whiteboard',
title: 'Whiteboard',
description: 'Excalidraw collaborative drawing board',
group: 'Services',
path: '/app/services/excalidraw',
icon: 'EditOutlined',
@ -540,6 +652,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-team-chat',
title: 'Team Chat',
description: 'Rocket.Chat team messaging and channels',
group: 'Services',
path: '/app/services/rocketchat',
icon: 'MessageOutlined',
@ -550,6 +663,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'nav-events',
title: 'Events',
description: 'Gancio event calendar and scheduling',
group: 'Services',
path: '/app/services/gancio',
icon: 'CalendarOutlined',
@ -557,9 +671,22 @@ export const commandRegistry: CommandItem[] = [
category: 'navigation',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-jitsi',
title: 'Video Meetings',
description: 'Jitsi Meet video conferencing setup',
group: 'Services',
path: '/app/services/jitsi',
icon: 'PlaySquareOutlined',
keywords: ['jitsi', 'video call', 'conference', 'meeting', 'webrtc'],
category: 'navigation',
featureFlag: 'enableMeet',
requiredRoles: ['SUPER_ADMIN'],
},
{
id: 'nav-qr-codes',
title: 'QR Codes',
description: 'Generate QR code images for links',
group: 'Services',
path: '/app/services/miniqr',
icon: 'QrcodeOutlined',
@ -572,6 +699,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-general',
title: 'General Settings',
description: 'Organization name, branding, and basic config',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
@ -583,6 +711,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-features',
title: 'Feature Flags',
description: 'Toggle platform modules on or off',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
@ -594,6 +723,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-theme',
title: 'Theme Settings',
description: 'Customize colors, appearance, and branding',
group: 'Settings',
path: '/app/settings',
icon: 'SettingOutlined',
@ -605,6 +735,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-email',
title: 'Email Settings',
description: 'SMTP server and email sender configuration',
group: 'Settings',
path: '/app/settings',
icon: 'MailOutlined',
@ -616,6 +747,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-provisioning',
title: 'User Provisioning',
description: 'Auto-create accounts in Gitea, Vaultwarden, and more',
group: 'Settings',
path: '/app/settings',
icon: 'TeamOutlined',
@ -627,6 +759,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-navigation',
title: 'Navigation Settings',
description: 'Configure public site header links and menus',
group: 'Settings',
path: '/app/navigation',
icon: 'GlobalOutlined',
@ -636,6 +769,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-map',
title: 'Map Settings',
description: 'Set map center, zoom, and geocoding provider',
group: 'Settings',
path: '/app/map/settings',
icon: 'EnvironmentOutlined',
@ -646,6 +780,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'settings-payments',
title: 'Payment Settings',
description: 'Stripe gateway and payment configuration',
group: 'Settings',
path: '/app/payments/settings',
icon: 'DollarOutlined',
@ -659,6 +794,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-create-campaign',
title: 'Create Campaign',
description: 'Start a new advocacy email campaign',
group: 'Actions',
path: '/app/campaigns',
icon: 'SendOutlined',
@ -670,6 +806,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-add-location',
title: 'Add Location',
description: 'Add a new address to the map database',
group: 'Actions',
path: '/app/map',
icon: 'EnvironmentOutlined',
@ -681,6 +818,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-shift',
title: 'New Shift',
description: 'Schedule a new volunteer shift',
group: 'Actions',
path: '/app/map/shifts',
icon: 'ScheduleOutlined',
@ -692,6 +830,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-landing-page',
title: 'New Landing Page',
description: 'Create a new landing page with the editor',
group: 'Actions',
path: '/app/pages',
icon: 'FileTextOutlined',
@ -703,6 +842,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-email-template',
title: 'New Email Template',
description: 'Create a reusable email template',
group: 'Actions',
path: '/app/email-templates',
icon: 'FileTextOutlined',
@ -714,6 +854,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-product',
title: 'New Product',
description: 'Add a new product to the store',
group: 'Actions',
path: '/app/payments/products',
icon: 'ShoppingOutlined',
@ -726,6 +867,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-add-contact',
title: 'Add Contact',
description: 'Create a new CRM contact record',
group: 'Actions',
path: '/app/people',
icon: 'ContactsOutlined',
@ -738,6 +880,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-plan',
title: 'New Plan',
description: 'Create a new subscription pricing plan',
group: 'Actions',
path: '/app/payments/plans',
icon: 'TagOutlined',
@ -750,6 +893,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-donation-page',
title: 'New Donation Page',
description: 'Create a new fundraising page',
group: 'Actions',
path: '/app/payments/donation-pages',
icon: 'HeartOutlined',
@ -762,6 +906,7 @@ export const commandRegistry: CommandItem[] = [
{
id: 'action-new-sms-campaign',
title: 'New SMS Campaign',
description: 'Start a new bulk text message campaign',
group: 'Actions',
path: '/app/sms/campaigns',
icon: 'PhoneOutlined',
@ -770,4 +915,16 @@ export const commandRegistry: CommandItem[] = [
featureFlag: 'enableSms',
navigationState: { openCreate: true },
},
{
id: 'action-new-sms-template',
title: 'New SMS Template',
description: 'Create a reusable text message template',
group: 'Actions',
path: '/app/sms/templates',
icon: 'FileTextOutlined',
keywords: ['create sms template', 'new message template', 'canned response'],
category: 'action',
featureFlag: 'enableSms',
navigationState: { openCreate: true },
},
];

View File

@ -0,0 +1,69 @@
/**
* Search scope definitions for the command palette.
* Users type `@prefix:query` to restrict results to a specific domain.
*/
export interface ScopeDefinition {
/** The prefix users type, e.g. "user" for `@user:` */
prefix: string;
/** Human-readable label shown in the scope list */
label: string;
/** Command registry groups to include */
groups: string[];
/** Entity types to include (from useEntitySearch configs) */
entityTypes?: string[];
}
export const SCOPE_DEFINITIONS: ScopeDefinition[] = [
{ prefix: 'user', label: 'People & Access', groups: ['People & Access'], entityTypes: ['User', 'Person'] },
{ prefix: 'campaign', label: 'Campaigns', groups: ['Advocacy'], entityTypes: ['Campaign'] },
{ prefix: 'sms', label: 'Broadcast (SMS)', groups: ['Broadcast'] },
{ prefix: 'settings', label: 'Settings', groups: ['Settings'] },
{ prefix: 'media', label: 'Media', groups: ['Media'] },
{ prefix: 'map', label: 'Map & Locations', groups: ['Map'], entityTypes: ['Location', 'Shift'] },
{ prefix: 'web', label: 'Web & Docs', groups: ['Web'], entityTypes: ['Landing Page', 'Email Template', 'Doc File'] },
{ prefix: 'service', label: 'Services', groups: ['Services'] },
{ prefix: 'payment', label: 'Payments', groups: ['Payments'], entityTypes: ['Product', 'Donation Page', 'Plan'] },
{ prefix: 'social', label: 'Social', groups: ['Social'] },
];
const SCOPE_MAP = new Map(SCOPE_DEFINITIONS.map((s) => [s.prefix, s]));
export interface ParsedQuery {
/** The active scope, or null if no valid scope prefix */
scope: ScopeDefinition | null;
/** The query string with scope prefix stripped */
strippedQuery: string;
/** Whether the user typed a bare `@` (show scope list) */
showScopeList: boolean;
}
/**
* Parse a raw query string for scope prefixes.
*
* - `@user:john` scope=user, strippedQuery="john"
* - `@user:` scope=user, strippedQuery=""
* - `@` showScopeList=true
* - `@foo:bar` invalid prefix, treated as normal search
* - `hello` no scope, strippedQuery="hello"
*/
export function parseQuery(raw: string): ParsedQuery {
const trimmed = raw.trim();
// Bare `@` — show scope selector
if (trimmed === '@') {
return { scope: null, strippedQuery: '', showScopeList: true };
}
// Match `@prefix:rest`
const match = trimmed.match(/^@(\w+):(.*)$/);
if (match) {
const scope = SCOPE_MAP.get(match[1]!.toLowerCase()) ?? null;
if (scope) {
return { scope, strippedQuery: match[2]!.trimStart(), showScopeList: false };
}
// Invalid prefix — fall through to normal search
}
return { scope: null, strippedQuery: trimmed, showScopeList: false };
}

View File

@ -5,6 +5,7 @@ export type CommandCategory = 'navigation' | 'settings' | 'action';
export interface CommandItem {
id: string;
title: string;
description?: string;
group: string;
path: string;
keywords: string[];

View File

@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import MiniSearch from 'minisearch';
import { commandRegistry } from './registry';
import type { CommandItem } from './types';
@ -11,6 +12,33 @@ interface IndexedItem extends CommandItem {
keywordsJoined: string;
}
/** Map current path prefixes to the command groups they relate to */
const CONTEXT_PREFIXES: Record<string, string[]> = {
'/app/campaigns': ['Advocacy'],
'/app/campaign-moderation': ['Advocacy'],
'/app/representatives': ['Advocacy'],
'/app/email-queue': ['Advocacy'],
'/app/responses': ['Advocacy'],
'/app/influence': ['Advocacy'],
'/app/sms': ['Broadcast'],
'/app/listmonk': ['Broadcast'],
'/app/email-templates': ['Broadcast'],
'/app/map': ['Map'],
'/app/media': ['Media'],
'/app/pages': ['Web'],
'/app/docs': ['Web'],
'/app/code': ['Web'],
'/app/navigation': ['Web'],
'/app/payments': ['Payments'],
'/app/social': ['Social'],
'/app/services': ['Services'],
'/app/tunnel': ['Services'],
'/app/observability': ['Services'],
'/app/settings': ['Settings'],
'/app/users': ['People & Access'],
'/app/people': ['People & Access'],
};
/**
* Builds a MiniSearch index from the command registry,
* filtered by the current user's roles and enabled feature flags.
@ -18,6 +46,7 @@ interface IndexedItem extends CommandItem {
export function useCommandIndex() {
const { settings } = useSettingsStore();
const { user } = useAuthStore();
const location = useLocation();
const { search, allItems } = useMemo(() => {
const userRoles = user ? getUserRoles(user) : [];
@ -28,8 +57,9 @@ export function useCommandIndex() {
if (!flag) return true;
if (!settings) return true; // show all when settings haven't loaded
const value = (settings as unknown as Record<string, unknown>)[flag];
// enablePayments, enableSms, and enablePeople default off, others default on
if (flag === 'enablePayments' || flag === 'enableSms' || flag === 'enablePeople') return value === true;
// Flags that default to false — only show when explicitly enabled
const defaultsOff = ['enablePayments', 'enableSms', 'enablePeople', 'enableSocial', 'enableChat', 'enableMeet'];
if (defaultsOff.includes(flag)) return value === true;
return value !== false;
};
@ -57,7 +87,9 @@ export function useCommandIndex() {
searchOptions: {
boost: { title: 10, keywordsJoined: 3, group: 1 },
prefix: true,
fuzzy: 0.2,
// Only allow fuzzy matching for terms 5+ chars to prevent
// false positives like "test" → "text"
fuzzy: (term) => (term.length >= 5 ? 0.2 : false),
},
});
@ -66,16 +98,55 @@ export function useCommandIndex() {
// Create lookup map for fast retrieval
const itemMap = new Map(filtered.map((item) => [item.id, item]));
const searchFn = (query: string): CommandItem[] => {
// Resolve contextual groups from current path
const currentPath = location.pathname;
const contextGroups: string[] = [];
if (currentPath !== '/app') {
for (const [prefix, groups] of Object.entries(CONTEXT_PREFIXES)) {
if (currentPath.startsWith(prefix)) {
contextGroups.push(...groups);
break;
}
}
}
const searchFn = (query: string, scopeGroups?: string[]): CommandItem[] => {
if (!query || query.length < 1) return [];
const results = idx.search(query);
return results
.map((r) => itemMap.get(r.id as string))
.filter((item): item is CommandItem => !!item);
// Map to items with scores
const scored = results
.map((r) => {
const item = itemMap.get(r.id as string);
if (!item) return null;
// Apply scope filter: skip items not in scope groups
if (scopeGroups && scopeGroups.length > 0) {
if (!scopeGroups.includes(item.group)) return null;
}
// Apply contextual boost
let boostedScore = r.score;
if (contextGroups.length > 0) {
if (currentPath.startsWith(item.path) && item.path !== '/app') {
boostedScore += 50;
} else if (contextGroups.includes(item.group)) {
boostedScore += 25;
}
}
return { item, score: boostedScore };
})
.filter((entry): entry is { item: CommandItem; score: number } => !!entry);
// Re-sort by boosted score (descending)
scored.sort((a, b) => b.score - a.score);
return scored.map((s) => s.item);
};
return { search: searchFn, allItems: filtered };
}, [settings, user]);
}, [settings, user, location.pathname]);
return { search, allItems };
}

View File

@ -130,8 +130,10 @@ const entityConfigs: EntitySearchConfig[] = [
/**
* Debounced API entity search. Fires after 300ms when query is 2+ chars.
* Uses AbortController to cancel in-flight requests on query change.
*
* @param scopeEntityTypes when provided, only search these entity types
*/
export function useEntitySearch(query: string) {
export function useEntitySearch(query: string, scopeEntityTypes?: string[]) {
const [results, setResults] = useState<EntityResult[]>([]);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
@ -154,11 +156,16 @@ export function useEntitySearch(query: string) {
const timer = setTimeout(async () => {
try {
// Filter configs by feature flags + roles
// Filter configs by feature flags + roles + scope
const activeConfigs = entityConfigs.filter((cfg) => {
// Scope filter: only include entity types in the active scope
if (scopeEntityTypes && scopeEntityTypes.length > 0) {
if (!scopeEntityTypes.includes(cfg.entityType)) return false;
}
if (cfg.featureFlag && settings) {
const val = (settings as unknown as Record<string, unknown>)[cfg.featureFlag];
if (cfg.featureFlag === 'enablePayments' || cfg.featureFlag === 'enableSms' || cfg.featureFlag === 'enablePeople') {
const defaultsOff = ['enablePayments', 'enableSms', 'enablePeople', 'enableSocial', 'enableChat', 'enableMeet'];
if (defaultsOff.includes(cfg.featureFlag)) {
if (val !== true) return false;
} else if (val === false) return false;
}
@ -210,7 +217,7 @@ export function useEntitySearch(query: string) {
clearTimeout(timer);
controller.abort();
};
}, [query, settings, user]);
}, [query, settings, user, scopeEntityTypes]);
return { results, loading };
}

View File

@ -106,30 +106,59 @@ const connectionTypeOptions = Object.entries(CONNECTION_TYPE_LABELS).map(([value
}));
function applyDagreLayout(nodes: Node[], edges: Edge[]): Node[] {
// Separate connected nodes (have at least one edge) from isolated ones
const connectedIds = new Set<string>();
for (const e of edges) {
connectedIds.add(e.source);
connectedIds.add(e.target);
}
const connectedNodes = nodes.filter((n) => connectedIds.has(n.id));
const isolatedNodes = nodes.filter((n) => !connectedIds.has(n.id));
// Layout connected nodes with dagre (tree layout)
let dagreMaxY = 0;
if (connectedNodes.length > 0) {
const g = new dagre.graphlib.Graph();
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: 'TB', nodesep: 80, ranksep: 100 });
nodes.forEach((node) => {
connectedNodes.forEach((node) => {
g.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
});
edges.forEach((edge) => {
g.setEdge(edge.source, edge.target);
});
dagre.layout(g);
return nodes.map((node) => {
const nodeWithPosition = g.node(node.id);
return {
...node,
position: {
x: nodeWithPosition.x - NODE_WIDTH / 2,
y: nodeWithPosition.y - NODE_HEIGHT / 2,
},
for (const node of connectedNodes) {
const pos = g.node(node.id);
node.position = { x: pos.x - NODE_WIDTH / 2, y: pos.y - NODE_HEIGHT / 2 };
dagreMaxY = Math.max(dagreMaxY, pos.y + NODE_HEIGHT / 2);
}
}
// Arrange isolated nodes in a grid below the connected graph
if (isolatedNodes.length > 0) {
const GAP_X = NODE_WIDTH + 30;
const GAP_Y = NODE_HEIGHT + 30;
const cols = Math.max(1, Math.ceil(Math.sqrt(isolatedNodes.length)));
const startY = connectedNodes.length > 0 ? dagreMaxY + 80 : 0;
// Center the grid horizontally
const gridWidth = cols * GAP_X;
const startX = -gridWidth / 2;
isolatedNodes.forEach((node, i) => {
const col = i % cols;
const row = Math.floor(i / cols);
node.position = {
x: startX + col * GAP_X,
y: startY + row * GAP_Y,
};
});
}
return [...connectedNodes, ...isolatedNodes];
}
interface ConnectionGraphProps {

View File

@ -0,0 +1,586 @@
/**
* Self-contained scheduling poll widget for GrapesJS landing pages.
* Rendered via createRoot() outside the App's ConfigProvider uses inline styles only, no Ant Design.
* Follows the same pattern as CampaignFormWidget, DonationWidget, etc.
*/
import { useState, useEffect, useCallback } from 'react';
import axios from 'axios';
const apiBase = '/api';
// Theme colors matching the dark public pages
const COLORS = {
bg: '#0d1b2a',
card: '#1b2838',
cardAlt: '#243447',
primary: '#fa8c16',
primaryHover: '#d46b08',
text: '#fff',
textMuted: 'rgba(255,255,255,0.65)',
border: 'rgba(255,255,255,0.15)',
success: '#52c41a',
error: '#ff4d4f',
yes: '#52c41a',
ifNeedBe: '#faad14',
no: '#d9d9d9',
};
const STATUS_COLORS: Record<string, string> = {
OPEN: '#52c41a',
CLOSED: '#fa8c16',
FINALIZED: '#1890ff',
CANCELLED: '#ff4d4f',
};
const STATUS_LABELS: Record<string, string> = {
OPEN: 'Open',
CLOSED: 'Closed',
FINALIZED: 'Finalized',
CANCELLED: 'Cancelled',
};
const VOTE_LABELS: Record<string, string> = {
YES: 'Yes',
IF_NEED_BE: 'If Need Be',
NO: 'No',
};
const VOTE_COLORS: Record<string, string> = {
YES: COLORS.yes,
IF_NEED_BE: COLORS.ifNeedBe,
NO: COLORS.no,
};
interface PollOption {
id: string;
date: string;
startTime: string;
endTime: string;
yesCount?: number;
ifNeedBeCount?: number;
noCount?: number;
score?: number;
}
interface PollVoter {
name: string;
votes: Record<string, string>;
}
interface PollComment {
id: string;
authorName: string;
content: string;
createdAt: string;
}
interface PollData {
id: string;
slug: string;
title: string;
description: string | null;
location: string | null;
status: string;
timezone: string;
votingDeadline: string | null;
finalizedOptionId: string | null;
finalizedOption: PollOption | null;
allowAnonymous: boolean;
createdBy?: { name: string | null; email: string };
options: PollOption[];
voters: PollVoter[];
comments: PollComment[];
}
interface SchedulingPollWidgetProps {
pollSlug: string;
showComments?: boolean;
title?: string;
}
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
border: `1px solid ${COLORS.border}`,
borderRadius: 6,
background: 'rgba(255,255,255,0.05)',
color: COLORS.text,
fontSize: 14,
outline: 'none',
boxSizing: 'border-box',
};
const btnStyle: React.CSSProperties = {
padding: '10px 24px',
background: COLORS.primary,
color: '#fff',
border: 'none',
borderRadius: 6,
fontWeight: 600,
fontSize: 14,
cursor: 'pointer',
width: '100%',
};
function formatDate(dateStr: string): string {
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
}
function formatDateTime(dateStr: string): string {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
}
const VOTER_TOKEN_KEY = 'poll_voter_token_';
export function SchedulingPollWidget({ pollSlug, showComments = true, title }: SchedulingPollWidgetProps) {
const [poll, setPoll] = useState<PollData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Vote form
const [voterName, setVoterName] = useState('');
const [voterEmail, setVoterEmail] = useState('');
const [votes, setVotes] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [hasVoted, setHasVoted] = useState(false);
const [submitMsg, setSubmitMsg] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Comment form
const [commentName, setCommentName] = useState('');
const [commentContent, setCommentContent] = useState('');
const [commentSubmitting, setCommentSubmitting] = useState(false);
const fetchPoll = useCallback(async () => {
try {
const { data } = await axios.get<PollData>(`${apiBase}/meeting-planner/public/${pollSlug}`);
setPoll(data);
// Check for stored voter token
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug);
if (storedToken) {
setHasVoted(true);
}
} catch {
setError('Poll not found or unavailable');
} finally {
setLoading(false);
}
}, [pollSlug]);
useEffect(() => { fetchPoll(); }, [fetchPoll]);
const handleVoteChange = (optionId: string, value: string) => {
setVotes((prev) => ({ ...prev, [optionId]: value }));
};
const handleSubmitVotes = async () => {
if (!poll || !voterName.trim()) {
setSubmitMsg({ type: 'error', text: 'Please enter your name' });
return;
}
if (!Object.keys(votes).length) {
setSubmitMsg({ type: 'error', text: 'Please vote on at least one option' });
return;
}
setSubmitting(true);
setSubmitMsg(null);
try {
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + pollSlug);
const { data } = await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/vote`, {
voterName: voterName.trim(),
voterEmail: voterEmail.trim() || undefined,
voterToken: storedToken || undefined,
votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })),
});
if (data.voterToken) {
localStorage.setItem(VOTER_TOKEN_KEY + pollSlug, data.voterToken);
}
setSubmitMsg({ type: 'success', text: hasVoted ? 'Votes updated!' : 'Votes submitted!' });
setHasVoted(true);
fetchPoll();
} catch (err: any) {
setSubmitMsg({ type: 'error', text: err.response?.data?.error?.message || 'Failed to submit votes' });
} finally {
setSubmitting(false);
}
};
const handleSubmitComment = async () => {
if (!commentName.trim() || !commentContent.trim()) return;
setCommentSubmitting(true);
try {
await axios.post(`${apiBase}/meeting-planner/public/${pollSlug}/comment`, {
authorName: commentName.trim(),
content: commentContent.trim(),
});
setCommentContent('');
fetchPoll();
} catch {
// Silent fail
} finally {
setCommentSubmitting(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 40, color: COLORS.textMuted }}>
Loading poll...
</div>
);
}
if (error || !poll) {
return (
<div style={{ textAlign: 'center', padding: 40, color: COLORS.textMuted }}>
{error || 'Poll not found'}
</div>
);
}
const isOpen = poll.status === 'OPEN';
const isFinalized = poll.status === 'FINALIZED';
const bestScore = poll.options.length ? Math.max(...poll.options.map((o) => o.score ?? 0)) : 0;
return (
<div style={{ maxWidth: 700, margin: '0 auto', fontFamily: "'Inter', -apple-system, sans-serif", color: COLORS.text }}>
{/* Title */}
{title && (
<h2 style={{ textAlign: 'center', marginBottom: 8, fontSize: '1.5rem', fontWeight: 700 }}>
{title}
</h2>
)}
{/* Poll header */}
<div style={{ marginBottom: 16 }}>
<h3 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>
{poll.title}
</h3>
{poll.description && (
<p style={{ color: COLORS.textMuted, margin: '0 0 8px', lineHeight: 1.5 }}>
{poll.description}
</p>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
<span style={{
display: 'inline-block',
padding: '2px 10px',
borderRadius: 4,
fontSize: 12,
fontWeight: 600,
background: `${STATUS_COLORS[poll.status] || '#666'}22`,
color: STATUS_COLORS[poll.status] || '#666',
border: `1px solid ${STATUS_COLORS[poll.status] || '#666'}44`,
}}>
{STATUS_LABELS[poll.status] || poll.status}
</span>
{poll.location && (
<span style={{ fontSize: 13, color: COLORS.textMuted }}>
{poll.location}
</span>
)}
<span style={{ fontSize: 13, color: COLORS.textMuted }}>
{poll.timezone}
</span>
</div>
</div>
{/* Deadline */}
{poll.votingDeadline && isOpen && (
<div style={{
padding: '8px 14px',
borderRadius: 6,
background: 'rgba(250,140,22,0.1)',
border: '1px solid rgba(250,140,22,0.3)',
marginBottom: 16,
fontSize: 13,
color: COLORS.primary,
}}>
Voting deadline: {formatDateTime(poll.votingDeadline)}
</div>
)}
{/* Finalized banner */}
{isFinalized && poll.finalizedOption && (
<div style={{
padding: '12px 16px',
borderRadius: 6,
background: 'rgba(82,196,26,0.1)',
border: '1px solid rgba(82,196,26,0.3)',
marginBottom: 16,
color: COLORS.success,
}}>
<strong>Date Confirmed:</strong>{' '}
{formatDate(poll.finalizedOption.date)} {poll.finalizedOption.startTime}{poll.finalizedOption.endTime}
</div>
)}
{/* Options table */}
<div style={{
background: COLORS.card,
borderRadius: 8,
border: `1px solid ${COLORS.border}`,
overflow: 'hidden',
marginBottom: 16,
}}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ padding: '10px 14px', borderBottom: `2px solid ${COLORS.border}`, textAlign: 'left', minWidth: 130 }}>
Participant
</th>
{poll.options.map((opt) => (
<th
key={opt.id}
style={{
padding: '10px 14px',
borderBottom: `2px solid ${COLORS.border}`,
textAlign: 'center',
minWidth: 100,
background: isFinalized && poll.finalizedOptionId === opt.id
? 'rgba(82,196,26,0.12)'
: opt.score === bestScore && bestScore > 0
? 'rgba(82,196,26,0.06)'
: undefined,
}}
>
<div style={{ fontWeight: 600 }}>{formatDate(opt.date)}</div>
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}{opt.endTime}</div>
{isFinalized && poll.finalizedOptionId === opt.id && (
<span style={{
display: 'inline-block',
marginTop: 4,
padding: '1px 6px',
borderRadius: 3,
fontSize: 10,
fontWeight: 600,
background: 'rgba(82,196,26,0.2)',
color: COLORS.success,
}}>
Confirmed
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{poll.voters.map((voter, i) => (
<tr key={i}>
<td style={{ padding: '8px 14px', borderBottom: `1px solid ${COLORS.border}` }}>
{voter.name}
</td>
{poll.options.map((opt) => {
const value = voter.votes[opt.id];
return (
<td
key={opt.id}
style={{
padding: '8px 14px',
borderBottom: `1px solid ${COLORS.border}`,
textAlign: 'center',
background: value ? `${VOTE_COLORS[value] || '#666'}18` : undefined,
}}
>
{value && (
<span style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 3,
fontSize: 11,
fontWeight: 600,
background: `${VOTE_COLORS[value] || '#666'}22`,
color: VOTE_COLORS[value] || '#666',
}}>
{VOTE_LABELS[value] || value}
</span>
)}
</td>
);
})}
</tr>
))}
{/* Score row */}
<tr style={{ fontWeight: 600 }}>
<td style={{ padding: '10px 14px', borderTop: `2px solid ${COLORS.border}` }}>Score</td>
{poll.options.map((opt) => (
<td
key={opt.id}
style={{
padding: '10px 14px',
borderTop: `2px solid ${COLORS.border}`,
textAlign: 'center',
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82,196,26,0.1)' : undefined,
}}
>
<div style={{ fontSize: 16 }}>{opt.score ?? 0}</div>
<div style={{ fontSize: 11, opacity: 0.7 }}>
{opt.yesCount ?? 0}Y / {opt.ifNeedBeCount ?? 0}M / {opt.noCount ?? 0}N
</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
</div>
{/* Vote form */}
{isOpen && (
<div style={{
background: COLORS.card,
borderRadius: 8,
border: `1px solid ${COLORS.border}`,
padding: 20,
marginBottom: 16,
}}>
<h4 style={{ margin: '0 0 14px', fontSize: 15 }}>
{hasVoted ? 'Update Your Votes' : 'Cast Your Votes'}
</h4>
<div style={{ display: 'flex', gap: 12, marginBottom: 14, flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Your name"
value={voterName}
onChange={(e) => setVoterName(e.target.value)}
style={{ ...inputStyle, flex: '1 1 200px' }}
/>
<input
type="email"
placeholder="Email (optional)"
value={voterEmail}
onChange={(e) => setVoterEmail(e.target.value)}
style={{ ...inputStyle, flex: '1 1 200px' }}
/>
</div>
{poll.options.map((opt) => (
<div key={opt.id} style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexWrap: 'wrap' }}>
<span style={{ minWidth: 150, fontSize: 13 }}>
{formatDate(opt.date)} {opt.startTime}{opt.endTime}
</span>
<div style={{ display: 'flex', gap: 4 }}>
{(['YES', 'IF_NEED_BE', 'NO'] as const).map((val) => (
<button
key={val}
type="button"
onClick={() => handleVoteChange(opt.id, val)}
style={{
padding: '4px 12px',
border: `1px solid ${VOTE_COLORS[val]}66`,
borderRadius: 4,
fontSize: 12,
fontWeight: 600,
cursor: 'pointer',
background: votes[opt.id] === val ? `${VOTE_COLORS[val]}33` : 'transparent',
color: votes[opt.id] === val ? VOTE_COLORS[val] : COLORS.textMuted,
}}
>
{VOTE_LABELS[val]}
</button>
))}
</div>
</div>
))}
{submitMsg && (
<div style={{
padding: '8px 14px',
borderRadius: 6,
marginTop: 12,
fontSize: 13,
background: submitMsg.type === 'success' ? 'rgba(82,196,26,0.1)' : 'rgba(255,77,79,0.1)',
color: submitMsg.type === 'success' ? COLORS.success : COLORS.error,
border: `1px solid ${submitMsg.type === 'success' ? 'rgba(82,196,26,0.3)' : 'rgba(255,77,79,0.3)'}`,
}}>
{submitMsg.text}
</div>
)}
<button
type="button"
onClick={handleSubmitVotes}
disabled={submitting}
style={{
...btnStyle,
marginTop: 14,
opacity: submitting ? 0.6 : 1,
}}
>
{submitting ? 'Submitting...' : hasVoted ? 'Update Votes' : 'Submit Votes'}
</button>
</div>
)}
{/* Comments */}
{showComments && (
<div style={{
background: COLORS.card,
borderRadius: 8,
border: `1px solid ${COLORS.border}`,
padding: 20,
}}>
<h4 style={{ margin: '0 0 14px', fontSize: 15 }}>
Comments ({poll.comments.length})
</h4>
{poll.comments.map((comment) => (
<div key={comment.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: `1px solid ${COLORS.border}` }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<strong style={{ fontSize: 13 }}>{comment.authorName}</strong>
<span style={{ fontSize: 11, color: COLORS.textMuted }}>
{formatDateTime(comment.createdAt)}
</span>
</div>
<p style={{ margin: 0, fontSize: 13, color: COLORS.textMuted, lineHeight: 1.5 }}>
{comment.content}
</p>
</div>
))}
{poll.status !== 'CANCELLED' && (
<div style={{ marginTop: 12 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<input
type="text"
placeholder="Your name"
value={commentName}
onChange={(e) => setCommentName(e.target.value)}
style={{ ...inputStyle, flex: '0 0 140px' }}
/>
<input
type="text"
placeholder="Add a comment..."
value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}
style={{ ...inputStyle, flex: '1 1 200px' }}
onKeyDown={(e) => { if (e.key === 'Enter') handleSubmitComment(); }}
/>
<button
type="button"
onClick={handleSubmitComment}
disabled={commentSubmitting}
style={{
...btnStyle,
width: 'auto',
flex: '0 0 auto',
opacity: commentSubmitting ? 0.6 : 1,
}}
>
Post
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View File

@ -1,10 +1,47 @@
import '@ant-design/v5-patch-for-react-19';
import React from 'react';
import ReactDOM from 'react-dom/client';
import ErrorBoundary from './components/ErrorBoundary';
import App from './App';
// Catch unhandled chunk/module load errors globally (e.g., stale deployment)
// These fire as window errors when a <script> tag fails to load
window.addEventListener('error', (event) => {
const target = event.target as HTMLElement | null;
if (target?.tagName === 'SCRIPT' || target?.tagName === 'LINK') {
const reloadKey = 'cm_chunk_reload';
const lastReload = sessionStorage.getItem(reloadKey);
const now = Date.now();
if (!lastReload || now - parseInt(lastReload, 10) > 10000) {
sessionStorage.setItem(reloadKey, String(now));
window.location.reload();
}
}
}, true); // capture phase to catch resource load errors
// Catch unhandled promise rejections (dynamic import failures)
window.addEventListener('unhandledrejection', (event) => {
const msg = String(event.reason?.message || event.reason || '');
if (
msg.includes('Failed to fetch dynamically imported module') ||
msg.includes('error loading dynamically imported module') ||
msg.includes('Loading chunk') ||
msg.includes('Importing a module script failed')
) {
const reloadKey = 'cm_chunk_reload';
const lastReload = sessionStorage.getItem(reloadKey);
const now = Date.now();
if (!lastReload || now - parseInt(lastReload, 10) > 10000) {
sessionStorage.setItem(reloadKey, String(now));
window.location.reload();
}
}
});
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);

View File

@ -58,6 +58,7 @@ import {
ShoppingCartOutlined,
MobileOutlined,
DesktopOutlined,
CalendarOutlined,
} from '@ant-design/icons';
import Editor from '@monaco-editor/react';
import type { OnMount } from '@monaco-editor/react';
@ -359,6 +360,7 @@ const SNIPPETS: MkDocsSnippet[] = [
{ id: 'pricing-table', label: 'Pricing Table', group: 'insert', type: 'insert', template: '' },
{ id: 'product-card', label: 'Product Card', group: 'insert', type: 'insert', template: '' },
{ id: 'ad-insert', label: 'Ad', group: 'insert', type: 'insert', template: '' },
{ id: 'scheduling-poll', label: 'Scheduling Poll', group: 'insert', type: 'insert', template: '' },
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
];
@ -590,6 +592,8 @@ export default function DocsPage() {
const [donateInsertOpen, setDonateInsertOpen] = useState(false);
const [productInsertOpen, setProductInsertOpen] = useState(false);
const [adPickerOpen, setAdPickerOpen] = useState(false);
const [pollInsertOpen, setPollInsertOpen] = useState(false);
const [pollSlugInput, setPollSlugInput] = useState('');
const [dragOver, setDragOver] = useState(false);
const dragCounter = useRef(0);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -816,6 +820,13 @@ export default function DocsPage() {
return;
}
// Scheduling poll — opens slug input modal
if (snippetId === 'scheduling-poll') {
setPollSlugInput('');
setPollInsertOpen(true);
return;
}
// Pricing table — static CTA (plans are dynamic, so link out)
if (snippetId === 'pricing-table') {
const appUrl = config
@ -1058,6 +1069,23 @@ export default function DocsPage() {
setAdPickerOpen(false);
}, []);
const handlePollInsert = useCallback(() => {
const slug = pollSlugInput.trim();
if (!slug) return;
const html = `<div class="scheduling-poll-block" data-poll-slug="${slug}" data-show-comments="true" data-title="Vote on a Meeting Time">\n Loading poll...\n</div>`;
const ed = monacoEditorRef.current;
if (ed) {
const sel = ed.getSelection();
if (sel) {
ed.executeEdits('poll-block-insert', [{ range: sel, text: '\n' + html + '\n' }]);
}
}
setPollInsertOpen(false);
setPollSlugInput('');
}, [pollSlugInput]);
const handleCtxMenuClick = useCallback((snippetId: string) => {
setCtxMenu(null);
handleToolbarSnippet(snippetId);
@ -1976,7 +2004,7 @@ export default function DocsPage() {
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
key: s.id,
label: s.label,
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'photo-insert' ? <PictureOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'ad-insert' ? <BuildOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <FileMarkdownOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'photo-insert' ? <PictureOutlined /> : s.id === 'donate-button' ? <HeartOutlined /> : s.id === 'pricing-table' ? <CrownOutlined /> : s.id === 'product-card' ? <ShoppingCartOutlined /> : s.id === 'ad-insert' ? <BuildOutlined /> : s.id === 'scheduling-poll' ? <CalendarOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <FileMarkdownOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
onClick: () => handleToolbarSnippet(s.id),
})) }} trigger={['click']}>
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
@ -2190,6 +2218,27 @@ export default function DocsPage() {
onInsert={handleAdInsert}
/>
{/* Scheduling Poll Insert Modal */}
<Modal
title="Insert Scheduling Poll"
open={pollInsertOpen}
onOk={handlePollInsert}
onCancel={() => { setPollInsertOpen(false); setPollSlugInput(''); }}
okText="Insert"
okButtonProps={{ disabled: !pollSlugInput.trim() }}
>
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 12 }}>
Enter the slug of the scheduling poll to embed. You can find poll slugs in the Meeting Planner admin page.
</Typography.Text>
<Input
placeholder="e.g. team-meeting-march"
value={pollSlugInput}
onChange={(e) => setPollSlugInput(e.target.value)}
onPressEnter={handlePollInsert}
autoFocus
/>
</Modal>
{/* Custom right-click context menu with submenus */}
{ctxMenu && (
<div

View File

@ -0,0 +1,709 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Form,
Switch,
Popconfirm,
message,
Typography,
Row,
Col,
DatePicker,
TimePicker,
Drawer,
Card,
Tooltip,
Grid,
Divider,
Modal,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
SearchOutlined,
CalendarOutlined,
CopyOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
TeamOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type {
SchedulingPoll,
PollsListResponse,
PollDetailResponse,
SchedulingPollStatus,
PollVoteValue,
} from '@/types/api';
import {
POLL_STATUS_COLORS,
POLL_STATUS_LABELS,
VOTE_VALUE_COLORS,
VOTE_VALUE_LABELS,
} from '@/types/api';
const { Text, Title } = Typography;
const TIMEZONE_OPTIONS = [
'America/Vancouver',
'America/Edmonton',
'America/Regina',
'America/Winnipeg',
'America/Toronto',
'America/Halifax',
'America/St_Johns',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
];
export default function MeetingPlannerPage() {
const screens = Grid.useBreakpoint();
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<SchedulingPollStatus | undefined>();
// Create drawer
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm();
const [creating, setCreating] = useState(false);
// Detail drawer
const [detailOpen, setDetailOpen] = useState(false);
const [selectedPoll, setSelectedPoll] = useState<PollDetailResponse | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// Finalize modal
const [finalizeOpen, setFinalizeOpen] = useState(false);
const [selectedOptionId, setSelectedOptionId] = useState<string>('');
const [finalizing, setFinalizing] = useState(false);
// Convert modal
const [convertOpen, setConvertOpen] = useState(false);
const [convertForm] = Form.useForm();
const [converting, setConverting] = useState(false);
const fetchPolls = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, any> = { page: pagination.page, limit: pagination.limit };
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get<PollsListResponse>('/meeting-planner', { params });
setPolls(data.polls);
setPagination((p) => ({ ...p, total: data.pagination.total }));
} catch {
message.error('Failed to load polls');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, search, statusFilter]);
useEffect(() => { fetchPolls(); }, [fetchPolls]);
const fetchPollDetail = async (id: string) => {
setDetailLoading(true);
try {
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${id}`);
setSelectedPoll(data);
setDetailOpen(true);
} catch {
message.error('Failed to load poll details');
} finally {
setDetailLoading(false);
}
};
const handleCreate = async (values: any) => {
setCreating(true);
try {
const options = values.options.map((opt: any) => ({
date: opt.date.format('YYYY-MM-DD'),
startTime: opt.startTime.format('HH:mm'),
endTime: opt.endTime.format('HH:mm'),
}));
await api.post('/meeting-planner', {
title: values.title,
description: values.description,
location: values.location,
timezone: values.timezone,
allowAnonymous: values.allowAnonymous ?? true,
notifyOnVote: values.notifyOnVote ?? true,
votingDeadline: values.votingDeadline?.toISOString(),
options,
});
message.success('Poll created');
setCreateOpen(false);
createForm.resetFields();
fetchPolls();
} catch {
message.error('Failed to create poll');
} finally {
setCreating(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/meeting-planner/${id}`);
message.success('Poll deleted');
fetchPolls();
} catch {
message.error('Failed to delete poll');
}
};
const handleFinalize = async () => {
if (!selectedPoll || !selectedOptionId) return;
setFinalizing(true);
try {
await api.post(`/meeting-planner/${selectedPoll.id}/finalize`, {
optionId: selectedOptionId,
});
message.success('Poll finalized');
setFinalizeOpen(false);
fetchPollDetail(selectedPoll.id);
fetchPolls();
} catch {
message.error('Failed to finalize poll');
} finally {
setFinalizing(false);
}
};
const handleConvertToShift = async (values: any) => {
if (!selectedPoll) return;
setConverting(true);
try {
await api.post(`/meeting-planner/${selectedPoll.id}/convert-to-shift`, {
maxVolunteers: values.maxVolunteers,
isPublic: values.isPublic ?? true,
});
message.success('Converted to shift');
setConvertOpen(false);
convertForm.resetFields();
fetchPollDetail(selectedPoll.id);
} catch {
message.error('Failed to convert to shift');
} finally {
setConverting(false);
}
};
const handleConvertToEvent = async () => {
if (!selectedPoll) return;
try {
await api.post(`/meeting-planner/${selectedPoll.id}/convert-to-event`);
message.success('Converted to Gancio event');
fetchPollDetail(selectedPoll.id);
} catch {
message.error('Failed to convert to event');
}
};
const handleDeleteComment = async (commentId: string) => {
if (!selectedPoll) return;
try {
await api.delete(`/meeting-planner/${selectedPoll.id}/comments/${commentId}`);
message.success('Comment deleted');
fetchPollDetail(selectedPoll.id);
} catch {
message.error('Failed to delete comment');
}
};
const copyShareLink = (slug: string) => {
const url = `${window.location.origin}/poll/${slug}`;
navigator.clipboard.writeText(url);
message.success('Share link copied');
};
const columns: ColumnsType<SchedulingPoll> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Button type="link" onClick={() => fetchPollDetail(record.id)} style={{ padding: 0 }}>
{title}
</Button>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 110,
render: (status: SchedulingPollStatus) => (
<Tag color={POLL_STATUS_COLORS[status]}>{POLL_STATUS_LABELS[status]}</Tag>
),
},
{
title: 'Options',
key: 'options',
width: 80,
render: (_, record) => record._count?.options ?? 0,
},
{
title: 'Votes',
key: 'votes',
width: 80,
render: (_, record) => record._count?.votes ?? 0,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 120,
render: (date: string) => dayjs(date).format('MMM D, YYYY'),
responsive: ['md'],
},
{
title: 'Actions',
key: 'actions',
width: 140,
render: (_, record) => (
<Space size="small">
<Tooltip title="Copy share link">
<Button size="small" icon={<CopyOutlined />} onClick={() => copyShareLink(record.slug)} />
</Tooltip>
<Popconfirm title="Delete this poll?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
// --- Voting Matrix Component ---
const VotingMatrix = ({ poll }: { poll: PollDetailResponse }) => {
if (!poll.options?.length) return <Text type="secondary">No options yet</Text>;
const bestScore = Math.max(...poll.options.map((o) => o.score ?? 0));
return (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ padding: '8px 12px', borderBottom: '2px solid #303030', textAlign: 'left', minWidth: 120 }}>
Voter
</th>
{poll.options.map((opt) => (
<th
key={opt.id}
style={{
padding: '8px 12px',
borderBottom: '2px solid #303030',
textAlign: 'center',
minWidth: 100,
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.1)' : undefined,
}}
>
<div>{dayjs(opt.date).format('MMM D')}</div>
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}{opt.endTime}</div>
{poll.finalizedOptionId === opt.id && (
<Tag color="blue" style={{ marginTop: 4 }}>Selected</Tag>
)}
</th>
))}
</tr>
</thead>
<tbody>
{poll.voters?.map((voter, i) => (
<tr key={i}>
<td style={{ padding: '6px 12px', borderBottom: '1px solid #303030' }}>
{voter.name}
</td>
{poll.options.map((opt) => {
const value = voter.votes[opt.id] as PollVoteValue | undefined;
return (
<td
key={opt.id}
style={{
padding: '6px 12px',
borderBottom: '1px solid #303030',
textAlign: 'center',
background: value ? `${VOTE_VALUE_COLORS[value]}20` : undefined,
}}
>
{value ? (
<Tag
color={value === 'YES' ? 'green' : value === 'IF_NEED_BE' ? 'gold' : 'default'}
style={{ margin: 0 }}
>
{VOTE_VALUE_LABELS[value]}
</Tag>
) : (
<Text type="secondary"></Text>
)}
</td>
);
})}
</tr>
))}
{/* Tally row */}
<tr style={{ fontWeight: 600 }}>
<td style={{ padding: '8px 12px', borderTop: '2px solid #303030' }}>Score</td>
{poll.options.map((opt) => (
<td
key={opt.id}
style={{
padding: '8px 12px',
borderTop: '2px solid #303030',
textAlign: 'center',
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.15)' : undefined,
}}
>
<div>{opt.score ?? 0}</div>
<div style={{ fontSize: 11, opacity: 0.7 }}>
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
);
};
return (
<div style={{ padding: screens.md ? 24 : 16 }}>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Title level={4} style={{ margin: 0 }}>
<CalendarOutlined style={{ marginRight: 8 }} />
Meeting Planner
</Title>
</Col>
<Col>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
{screens.md ? 'Create Poll' : 'New'}
</Button>
</Col>
</Row>
{/* Filters */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search polls..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Select
placeholder="Filter by status"
style={{ width: '100%' }}
allowClear
value={statusFilter}
onChange={setStatusFilter}
options={Object.entries(POLL_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Col>
</Row>
<Table
dataSource={polls}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: false,
onChange: (page) => setPagination((p) => ({ ...p, page })),
}}
size="small"
/>
{/* Create Poll Drawer */}
<Drawer
title="Create Scheduling Poll"
open={createOpen}
onClose={() => setCreateOpen(false)}
width={screens.md ? 560 : '100%'}
>
<Form
form={createForm}
layout="vertical"
onFinish={handleCreate}
initialValues={{
timezone: 'America/Edmonton',
allowAnonymous: true,
notifyOnVote: true,
options: [
{ date: null, startTime: null, endTime: null },
{ date: null, startTime: null, endTime: null },
],
}}
>
<Form.Item name="title" label="Title" rules={[{ required: true }]}>
<Input placeholder="e.g., Team Planning Meeting" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="What is this meeting about?" />
</Form.Item>
<Form.Item name="location" label="Location">
<Input placeholder="e.g., Community Centre Room 204" />
</Form.Item>
<Form.Item name="timezone" label="Timezone">
<Select options={TIMEZONE_OPTIONS.map((tz) => ({ value: tz, label: tz }))} />
</Form.Item>
<Divider>Date/Time Options</Divider>
<Form.List name="options">
{(fields, { add, remove }) => (
<>
{fields.map(({ key, name }) => (
<Row key={key} gutter={8} align="middle" style={{ marginBottom: 8 }}>
<Col flex="auto">
<Space wrap>
<Form.Item name={[name, 'date']} noStyle rules={[{ required: true, message: 'Date required' }]}>
<DatePicker format="YYYY-MM-DD" placeholder="Date" />
</Form.Item>
<Form.Item name={[name, 'startTime']} noStyle rules={[{ required: true, message: 'Start required' }]}>
<TimePicker format="HH:mm" minuteStep={15} placeholder="Start" />
</Form.Item>
<Form.Item name={[name, 'endTime']} noStyle rules={[{ required: true, message: 'End required' }]}>
<TimePicker format="HH:mm" minuteStep={15} placeholder="End" />
</Form.Item>
</Space>
</Col>
<Col>
{fields.length > 2 && (
<Button size="small" danger icon={<DeleteOutlined />} onClick={() => remove(name)} />
)}
</Col>
</Row>
))}
<Button
type="dashed"
onClick={() => add({ date: null, startTime: null, endTime: null })}
block
icon={<PlusOutlined />}
disabled={fields.length >= 20}
>
Add Option
</Button>
</>
)}
</Form.List>
<Divider>Settings</Divider>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="notifyOnVote" label="Notify on Vote" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
</Row>
<Form.Item name="votingDeadline" label="Voting Deadline (optional)">
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Button type="primary" htmlType="submit" loading={creating} block>
Create Poll
</Button>
</Form>
</Drawer>
{/* Poll Detail Drawer */}
<Drawer
title={selectedPoll?.title || 'Poll Details'}
open={detailOpen}
onClose={() => { setDetailOpen(false); setSelectedPoll(null); }}
width={screens.md ? 720 : '100%'}
loading={detailLoading}
extra={
selectedPoll && (
<Space>
<Button
size="small"
icon={<CopyOutlined />}
onClick={() => copyShareLink(selectedPoll.slug)}
>
Share
</Button>
</Space>
)
}
>
{selectedPoll && (
<>
{/* Status + Meta */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col>
<Tag color={POLL_STATUS_COLORS[selectedPoll.status]}>
{POLL_STATUS_LABELS[selectedPoll.status]}
</Tag>
</Col>
<Col>
<Text type="secondary">
{selectedPoll._count?.votes ?? 0} votes across {selectedPoll._count?.options ?? 0} options
</Text>
</Col>
</Row>
{selectedPoll.description && (
<Text style={{ display: 'block', marginBottom: 16 }}>{selectedPoll.description}</Text>
)}
{selectedPoll.location && (
<Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
Location: {selectedPoll.location}
</Text>
)}
{/* Voting Matrix */}
<Card size="small" title="Voting Matrix" style={{ marginBottom: 16 }}>
<VotingMatrix poll={selectedPoll} />
</Card>
{/* Actions */}
<Card size="small" title="Actions" style={{ marginBottom: 16 }}>
<Space wrap>
{selectedPoll.status === 'OPEN' && (
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={() => {
setSelectedOptionId('');
setFinalizeOpen(true);
}}
>
Finalize
</Button>
)}
{selectedPoll.status === 'FINALIZED' && !selectedPoll.convertedShiftId && (
<Button
icon={<TeamOutlined />}
onClick={() => {
convertForm.resetFields();
setConvertOpen(true);
}}
>
Convert to Shift
</Button>
)}
{selectedPoll.status === 'FINALIZED' && !selectedPoll.convertedGancioEventId && (
<Popconfirm title="Convert to Gancio event?" onConfirm={handleConvertToEvent}>
<Button icon={<CalendarOutlined />}>Convert to Event</Button>
</Popconfirm>
)}
{selectedPoll.convertedShiftId && (
<Tag color="blue" icon={<CheckCircleOutlined />}>Converted to Shift</Tag>
)}
{selectedPoll.convertedGancioEventId && (
<Tag color="green" icon={<CheckCircleOutlined />}>Converted to Event</Tag>
)}
{selectedPoll.status === 'OPEN' && (
<Popconfirm
title="Close this poll?"
onConfirm={async () => {
await api.put(`/meeting-planner/${selectedPoll.id}`, { status: 'CLOSED' });
message.success('Poll closed');
fetchPollDetail(selectedPoll.id);
fetchPolls();
}}
>
<Button icon={<ClockCircleOutlined />}>Close</Button>
</Popconfirm>
)}
</Space>
</Card>
{/* Comments */}
{selectedPoll.comments && selectedPoll.comments.length > 0 && (
<Card size="small" title={`Comments (${selectedPoll.comments.length})`}>
{selectedPoll.comments.map((comment) => (
<div key={comment.id} style={{ marginBottom: 12, padding: '8px 0', borderBottom: '1px solid #303030' }}>
<Row justify="space-between">
<Col>
<Text strong>{comment.authorName}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
{dayjs(comment.createdAt).format('MMM D, h:mm A')}
</Text>
</Col>
<Col>
<Popconfirm title="Delete comment?" onConfirm={() => handleDeleteComment(comment.id)}>
<Button size="small" danger type="text" icon={<DeleteOutlined />} />
</Popconfirm>
</Col>
</Row>
<Text style={{ display: 'block', marginTop: 4 }}>{comment.content}</Text>
</div>
))}
</Card>
)}
</>
)}
</Drawer>
{/* Finalize Modal */}
<Modal
title="Finalize Poll"
open={finalizeOpen}
onCancel={() => setFinalizeOpen(false)}
onOk={handleFinalize}
confirmLoading={finalizing}
okText="Finalize"
okButtonProps={{ disabled: !selectedOptionId }}
>
<Text style={{ display: 'block', marginBottom: 16 }}>
Select the winning date/time option:
</Text>
<Select
placeholder="Select option"
style={{ width: '100%' }}
value={selectedOptionId || undefined}
onChange={setSelectedOptionId}
options={selectedPoll?.options?.map((opt) => ({
value: opt.id,
label: `${dayjs(opt.date).format('MMM D, YYYY')} ${opt.startTime}${opt.endTime} (score: ${opt.score ?? 0})`,
}))}
/>
</Modal>
{/* Convert to Shift Modal */}
<Modal
title="Convert to Volunteer Shift"
open={convertOpen}
onCancel={() => setConvertOpen(false)}
onOk={() => convertForm.submit()}
confirmLoading={converting}
okText="Create Shift"
>
<Form form={convertForm} layout="vertical" onFinish={handleConvertToShift} initialValues={{ maxVolunteers: 10, isPublic: true }}>
<Form.Item name="maxVolunteers" label="Max Volunteers" rules={[{ required: true }]}>
<Input type="number" min={1} />
</Form.Item>
<Form.Item name="isPublic" label="Public" valuePropName="checked">
<Switch />
</Form.Item>
</Form>
</Modal>
</div>
);
}

View File

@ -0,0 +1,92 @@
import { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Result, Button, Input, App, Space } from 'antd';
import { ArrowLeftOutlined, HomeOutlined, MailOutlined } from '@ant-design/icons';
import axios from 'axios';
import { useAuthStore } from '@/stores/auth.store';
import { isAdmin } from '@/utils/roles';
const API_URL = import.meta.env.VITE_API_URL || '';
export default function NotFoundPage() {
const navigate = useNavigate();
const location = useLocation();
const { message } = App.useApp();
const { user, isAuthenticated } = useAuthStore();
const [reporting, setReporting] = useState(false);
const [showInput, setShowInput] = useState(false);
const [reportMessage, setReportMessage] = useState('');
const homePath = !isAuthenticated
? '/home'
: user && isAdmin(user)
? '/app'
: '/volunteer';
const handleReport = async () => {
if (!showInput) {
setShowInput(true);
return;
}
setReporting(true);
try {
await axios.post(`${API_URL}/api/public/error-report`, {
url: location.pathname + location.search,
message: reportMessage || undefined,
userAgent: navigator.userAgent,
});
message.success('Report sent to admins. Thank you!');
setShowInput(false);
setReportMessage('');
} catch {
message.error('Failed to send report. Please try again later.');
} finally {
setReporting(false);
}
};
return (
<Result
status="404"
title="404"
subTitle={`The page "${location.pathname}" was not found.`}
extra={
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 400 }}>
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)}>
Go Back
</Button>
<Button type="primary" icon={<HomeOutlined />} onClick={() => navigate(homePath)}>
Go Home
</Button>
<Button
icon={<MailOutlined />}
onClick={handleReport}
loading={reporting}
>
Report to Admin
</Button>
</Space>
{showInput && (
<div>
<Input.TextArea
placeholder="Optional: describe what you were looking for..."
value={reportMessage}
onChange={(e) => setReportMessage(e.target.value)}
rows={3}
maxLength={500}
showCount
style={{ marginBottom: 8 }}
/>
<Button type="primary" onClick={handleReport} loading={reporting} block>
Send Report
</Button>
</div>
)}
</Space>
}
/>
);
}

View File

@ -505,7 +505,10 @@ export default function SettingsPage() {
<Form.Item label="Video Meetings (Jitsi)" name="enableMeet" valuePropName="checked" extra="Self-hosted video calls — integrates with Rocket.Chat channels" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="SMS Campaigns" name="enableSms" valuePropName="checked" extra="Termux Android SMS for campaign texting and conversations" style={{ marginBottom: 0 }}>
<Form.Item label="SMS Campaigns" name="enableSms" valuePropName="checked" extra="Termux Android SMS for campaign texting and conversations" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Meeting Planner" name="enableMeetingPlanner" valuePropName="checked" extra="Scheduling polls for finding the best meeting time" style={{ marginBottom: 0 }}>
<Switch />
</Form.Item>
</Card>

View File

@ -10,6 +10,7 @@ import { DonationWidget } from '@/components/payments/DonationWidget';
import { PricingWidget } from '@/components/payments/PricingWidget';
import { ProductWidget } from '@/components/payments/ProductWidget';
import { CampaignFormWidget } from '@/components/influence/CampaignFormWidget';
import { SchedulingPollWidget } from '@/components/scheduling/SchedulingPollWidget';
import GalleryAdCard from '@/components/media/GalleryAdCard';
import type { GalleryAd } from '@/types/gallery-ads';
@ -23,6 +24,7 @@ export default function PublicLandingPage() {
const paymentRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
const campaignFormRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
const adRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
const pollRootsRef = useRef<Array<ReturnType<typeof createRoot>>>([]);
// Track page view
useEffect(() => {
@ -322,12 +324,44 @@ export default function PublicLandingPage() {
}
};
// Hydrate scheduling poll blocks
const hydratePollBlocks = () => {
const pollBlocks = contentRef.current?.querySelectorAll('.scheduling-poll-block');
if (!pollBlocks) return;
pollRootsRef.current.forEach((root) => {
try { root.unmount(); } catch (err) { console.error('Failed to unmount poll root:', err); }
});
pollRootsRef.current = [];
pollBlocks.forEach((blockEl) => {
const pollSlug = blockEl.getAttribute('data-poll-slug');
if (!pollSlug) return;
const showComments = blockEl.getAttribute('data-show-comments') !== 'false';
const title = blockEl.getAttribute('data-title') || undefined;
const container = document.createElement('div');
blockEl.innerHTML = '';
blockEl.appendChild(container);
try {
const root = createRoot(container);
pollRootsRef.current.push(root);
root.render(<SchedulingPollWidget pollSlug={pollSlug} showComments={showComments} title={title} />);
} catch (err) {
console.error('Failed to render scheduling poll widget:', err);
}
});
};
// Hydrate after DOM is ready
setTimeout(hydrateVideoBlocks, 100);
setTimeout(hydrateVideoCards, 200);
setTimeout(hydratePaymentBlocks, 150);
setTimeout(hydrateCampaignFormBlocks, 175);
setTimeout(hydrateAdBlocks, 200);
setTimeout(hydratePollBlocks, 180);
// Cleanup on unmount
return () => {
@ -350,6 +384,11 @@ export default function PublicLandingPage() {
try { root.unmount(); } catch (err) { console.error('Failed to unmount ad root on cleanup:', err); }
});
adRootsRef.current = [];
pollRootsRef.current.forEach((root) => {
try { root.unmount(); } catch (err) { console.error('Failed to unmount poll root on cleanup:', err); }
});
pollRootsRef.current = [];
};
}, [page]);

View File

@ -0,0 +1,139 @@
import { useState, useEffect } from 'react';
import {
Typography,
Card,
Button,
Row,
Col,
Tag,
Spin,
Grid,
Space,
Empty,
} from 'antd';
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
TeamOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
import { POLL_STATUS_COLORS, POLL_STATUS_LABELS } from '@/types/api';
const { Title, Text, Paragraph } = Typography;
export default function PollsListPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const navigate = useNavigate();
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchPolls = async () => {
try {
const { data } = await axios.get<PollsListResponse>('/api/meeting-planner/public');
setPolls(data.polls);
} catch {
// If unauthorized, try the public listing approach
setPolls([]);
} finally {
setLoading(false);
}
};
fetchPolls();
}, []);
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: isMobile ? '16px 12px' : '24px 16px' }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 24 }}>
<CalendarOutlined style={{ marginRight: 8 }} />
Scheduling Polls
</Title>
{polls.length === 0 ? (
<Empty description="No open polls at the moment" />
) : (
<Row gutter={[16, 16]}>
{polls.map((poll) => {
const optionCount = poll._count?.options ?? 0;
const voteCount = poll._count?.votes ?? 0;
return (
<Col xs={24} sm={12} key={poll.id}>
<Card
hoverable
onClick={() => navigate(`/poll/${poll.slug}`)}
style={{ height: '100%' }}
>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<Row justify="space-between" align="top">
<Col flex="auto">
<Text strong style={{ fontSize: 16 }}>{poll.title}</Text>
</Col>
<Col>
<Tag color={POLL_STATUS_COLORS[poll.status as SchedulingPollStatus]}>
{POLL_STATUS_LABELS[poll.status as SchedulingPollStatus]}
</Tag>
</Col>
</Row>
{poll.description && (
<Paragraph
type="secondary"
ellipsis={{ rows: 2 }}
style={{ marginBottom: 0 }}
>
{poll.description}
</Paragraph>
)}
<Space split={<span style={{ opacity: 0.3 }}>|</span>} wrap>
<Text type="secondary" style={{ fontSize: 13 }}>
<CalendarOutlined style={{ marginRight: 4 }} />
{optionCount} options
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
<TeamOutlined style={{ marginRight: 4 }} />
{voteCount} votes
</Text>
{poll.location && (
<Text type="secondary" style={{ fontSize: 13 }}>
<EnvironmentOutlined style={{ marginRight: 4 }} />
{poll.location}
</Text>
)}
</Space>
{poll.votingDeadline && (
<Text type="secondary" style={{ fontSize: 12 }}>
<ClockCircleOutlined style={{ marginRight: 4 }} />
Deadline: {dayjs(poll.votingDeadline).format('MMM D, YYYY h:mm A')}
</Text>
)}
<Button type="primary" size="small" block style={{ marginTop: 8 }}>
Vote Now
</Button>
</Space>
</Card>
</Col>
);
})}
</Row>
)}
</div>
);
}

View File

@ -0,0 +1,501 @@
import { useState, useEffect, useCallback } from 'react';
import {
Typography,
Card,
Button,
Row,
Col,
Tag,
Input,
Radio,
message,
Spin,
Result,
Grid,
Space,
Divider,
Alert,
} from 'antd';
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
CheckCircleOutlined,
SendOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useParams } from 'react-router-dom';
import type {
PollDetailResponse,
PollVoteValue,
} from '@/types/api';
import {
POLL_STATUS_COLORS,
POLL_STATUS_LABELS,
VOTE_VALUE_COLORS,
VOTE_VALUE_LABELS,
} from '@/types/api';
import { useAuthStore } from '@/stores/auth.store';
const { Title, Text, Paragraph } = Typography;
const VOTER_TOKEN_KEY = 'poll_voter_token_';
export default function SchedulingPollPage() {
const { slug } = useParams<{ slug: string }>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const { user } = useAuthStore();
const [poll, setPoll] = useState<PollDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
// Vote form state
const [voterName, setVoterName] = useState(user?.name || '');
const [voterEmail, setVoterEmail] = useState(user?.email || '');
const [votes, setVotes] = useState<Record<string, PollVoteValue>>({});
const [submitting, setSubmitting] = useState(false);
const [hasVoted, setHasVoted] = useState(false);
// Comment form state
const [commentName, setCommentName] = useState(user?.name || '');
const [commentContent, setCommentContent] = useState('');
const [commentSubmitting, setCommentSubmitting] = useState(false);
const fetchPoll = useCallback(async () => {
if (!slug) return;
setLoading(true);
try {
const { data } = await axios.get<PollDetailResponse>(`/api/meeting-planner/public/${slug}`);
setPoll(data);
// Check if user has already voted (by token or auth)
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
if (storedToken || user) {
const existingVoter = data.voters?.find((v) => {
if (user) return data.votes?.some((vote) => vote.userId === user.id && v.name === vote.voterName);
return false;
});
if (existingVoter) {
setVoterName(existingVoter.name);
setVotes(existingVoter.votes);
setHasVoted(true);
} else if (storedToken) {
// Find voter by checking if any vote has our token
// We can't see tokens in the response, but if we stored one, we know we voted
setHasVoted(true);
}
}
} catch {
setError(true);
} finally {
setLoading(false);
}
}, [slug, user]);
useEffect(() => { fetchPoll(); }, [fetchPoll]);
const handleVoteChange = (optionId: string, value: PollVoteValue) => {
setVotes((prev) => ({ ...prev, [optionId]: value }));
};
const handleSubmitVotes = async () => {
if (!poll || !voterName.trim()) {
message.warning('Please enter your name');
return;
}
if (!Object.keys(votes).length) {
message.warning('Please vote on at least one option');
return;
}
setSubmitting(true);
try {
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
const { data } = await axios.post(`/api/meeting-planner/public/${slug}/vote`, {
voterName: voterName.trim(),
voterEmail: voterEmail.trim() || undefined,
voterToken: storedToken || undefined,
votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })),
});
// Store voter token for anonymous edit access
if (data.voterToken) {
localStorage.setItem(VOTER_TOKEN_KEY + slug, data.voterToken);
}
message.success(hasVoted ? 'Votes updated' : 'Votes submitted');
setHasVoted(true);
fetchPoll();
} catch (err: any) {
message.error(err.response?.data?.error?.message || 'Failed to submit votes');
} finally {
setSubmitting(false);
}
};
const handleSubmitComment = async () => {
if (!commentName.trim() || !commentContent.trim()) {
message.warning('Please enter your name and comment');
return;
}
setCommentSubmitting(true);
try {
await axios.post(`/api/meeting-planner/public/${slug}/comment`, {
authorName: commentName.trim(),
content: commentContent.trim(),
});
message.success('Comment added');
setCommentContent('');
fetchPoll();
} catch {
message.error('Failed to add comment');
} finally {
setCommentSubmitting(false);
}
};
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (error || !poll) {
return (
<Result
status="404"
title="Poll Not Found"
subTitle="This scheduling poll may have been removed or the link is invalid."
/>
);
}
const isOpen = poll.status === 'OPEN';
const isFinalized = poll.status === 'FINALIZED';
const bestScore = poll.options?.length ? Math.max(...poll.options.map((o) => o.score ?? 0)) : 0;
return (
<div style={{ maxWidth: 900, margin: '0 auto', padding: isMobile ? '16px 12px' : '24px 16px' }}>
{/* Header */}
<div style={{ marginBottom: 24 }}>
<Title level={isMobile ? 3 : 2} style={{ marginBottom: 8 }}>
<CalendarOutlined style={{ marginRight: 8 }} />
{poll.title}
</Title>
{poll.description && (
<Paragraph style={{ fontSize: 16, opacity: 0.85, marginBottom: 12 }}>
{poll.description}
</Paragraph>
)}
<Space wrap>
<Tag color={POLL_STATUS_COLORS[poll.status]}>{POLL_STATUS_LABELS[poll.status]}</Tag>
{poll.location && (
<Text type="secondary">
<EnvironmentOutlined style={{ marginRight: 4 }} />
{poll.location}
</Text>
)}
<Text type="secondary">
<ClockCircleOutlined style={{ marginRight: 4 }} />
{poll.timezone}
</Text>
{poll.createdBy && (
<Text type="secondary">
by {poll.createdBy.name || poll.createdBy.email}
</Text>
)}
</Space>
</div>
{/* Deadline banner */}
{poll.votingDeadline && isOpen && (
<Alert
type={dayjs(poll.votingDeadline).isBefore(dayjs()) ? 'error' : 'info'}
message={
dayjs(poll.votingDeadline).isBefore(dayjs())
? 'Voting deadline has passed'
: `Voting deadline: ${dayjs(poll.votingDeadline).format('MMM D, YYYY h:mm A')}`
}
showIcon
style={{ marginBottom: 16 }}
/>
)}
{/* Finalized banner */}
{isFinalized && poll.finalizedOption && (
<Alert
type="success"
message="Date Confirmed"
description={
<Space>
<CheckCircleOutlined />
<Text strong>
{dayjs(poll.finalizedOption.date).format('dddd, MMMM D, YYYY')} {poll.finalizedOption.startTime}{poll.finalizedOption.endTime}
</Text>
</Space>
}
showIcon
style={{ marginBottom: 24 }}
/>
)}
{/* Voting */}
{isMobile ? (
/* Mobile: vertical cards */
<div style={{ marginBottom: 24 }}>
{poll.options?.map((opt) => (
<Card
key={opt.id}
size="small"
style={{
marginBottom: 12,
border: isFinalized && poll.finalizedOptionId === opt.id ? '2px solid #52c41a' : undefined,
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.05)' : undefined,
}}
>
<Row justify="space-between" align="middle">
<Col>
<Text strong>{dayjs(opt.date).format('ddd, MMM D')}</Text>
<br />
<Text type="secondary">{opt.startTime} {opt.endTime}</Text>
</Col>
<Col>
<Text type="secondary" style={{ fontSize: 12 }}>
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
</Text>
</Col>
</Row>
{isOpen && (
<div style={{ marginTop: 12 }}>
<Radio.Group
value={votes[opt.id]}
onChange={(e) => handleVoteChange(opt.id, e.target.value)}
optionType="button"
buttonStyle="solid"
size="small"
>
<Radio.Button value="YES" style={{ color: votes[opt.id] === 'YES' ? '#fff' : undefined }}>Yes</Radio.Button>
<Radio.Button value="IF_NEED_BE">If Need Be</Radio.Button>
<Radio.Button value="NO">No</Radio.Button>
</Radio.Group>
</div>
)}
</Card>
))}
</div>
) : (
/* Desktop: voting matrix */
<Card size="small" style={{ marginBottom: 24 }}>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ padding: '10px 14px', borderBottom: '2px solid #303030', textAlign: 'left', minWidth: 140 }}>
Participant
</th>
{poll.options?.map((opt) => (
<th
key={opt.id}
style={{
padding: '10px 14px',
borderBottom: '2px solid #303030',
textAlign: 'center',
minWidth: 110,
background: isFinalized && poll.finalizedOptionId === opt.id
? 'rgba(82, 196, 26, 0.15)'
: opt.score === bestScore && bestScore > 0
? 'rgba(82, 196, 26, 0.08)'
: undefined,
}}
>
<div style={{ fontWeight: 600 }}>{dayjs(opt.date).format('ddd, MMM D')}</div>
<div style={{ fontSize: 11, opacity: 0.7 }}>{opt.startTime}{opt.endTime}</div>
{isFinalized && poll.finalizedOptionId === opt.id && (
<Tag color="green" style={{ marginTop: 4, fontSize: 10 }}>Confirmed</Tag>
)}
</th>
))}
</tr>
</thead>
<tbody>
{poll.voters?.map((voter, i) => (
<tr key={i}>
<td style={{ padding: '8px 14px', borderBottom: '1px solid #252525' }}>
{voter.name}
</td>
{poll.options?.map((opt) => {
const value = voter.votes[opt.id] as PollVoteValue | undefined;
return (
<td
key={opt.id}
style={{
padding: '8px 14px',
borderBottom: '1px solid #252525',
textAlign: 'center',
background: value ? `${VOTE_VALUE_COLORS[value]}18` : undefined,
}}
>
{value && (
<Tag
color={value === 'YES' ? 'green' : value === 'IF_NEED_BE' ? 'gold' : 'default'}
style={{ margin: 0 }}
>
{VOTE_VALUE_LABELS[value]}
</Tag>
)}
</td>
);
})}
</tr>
))}
{/* Score row */}
<tr style={{ fontWeight: 600 }}>
<td style={{ padding: '10px 14px', borderTop: '2px solid #303030' }}>Score</td>
{poll.options?.map((opt) => (
<td
key={opt.id}
style={{
padding: '10px 14px',
borderTop: '2px solid #303030',
textAlign: 'center',
background: opt.score === bestScore && bestScore > 0 ? 'rgba(82, 196, 26, 0.12)' : undefined,
}}
>
<div style={{ fontSize: 16 }}>{opt.score ?? 0}</div>
<div style={{ fontSize: 11, opacity: 0.7 }}>
{opt.yesCount}Y / {opt.ifNeedBeCount}M / {opt.noCount}N
</div>
</td>
))}
</tr>
</tbody>
</table>
</div>
</Card>
)}
{/* Vote Form */}
{isOpen && (
<Card
size="small"
title={hasVoted ? 'Update Your Votes' : 'Cast Your Votes'}
style={{ marginBottom: 24 }}
>
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12}>
<Input
placeholder="Your name"
value={voterName}
onChange={(e) => setVoterName(e.target.value)}
disabled={!!user}
/>
</Col>
<Col xs={24} sm={12}>
<Input
placeholder="Email (optional, for notifications)"
value={voterEmail}
onChange={(e) => setVoterEmail(e.target.value)}
disabled={!!user}
/>
</Col>
</Row>
{/* Desktop: inline radio buttons per option */}
{!isMobile && poll.options?.map((opt) => (
<Row key={opt.id} gutter={12} align="middle" style={{ marginBottom: 8 }}>
<Col flex="200px">
<Text>{dayjs(opt.date).format('ddd, MMM D')} {opt.startTime}{opt.endTime}</Text>
</Col>
<Col flex="auto">
<Radio.Group
value={votes[opt.id]}
onChange={(e) => handleVoteChange(opt.id, e.target.value)}
optionType="button"
buttonStyle="solid"
size="small"
>
<Radio.Button value="YES">Yes</Radio.Button>
<Radio.Button value="IF_NEED_BE">If Need Be</Radio.Button>
<Radio.Button value="NO">No</Radio.Button>
</Radio.Group>
</Col>
</Row>
))}
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSubmitVotes}
loading={submitting}
block
style={{ marginTop: 16 }}
>
{hasVoted ? 'Update Votes' : 'Submit Votes'}
</Button>
</Card>
)}
{/* Comments Section */}
<Card
size="small"
title={`Comments (${poll.comments?.length ?? 0})`}
style={{ marginBottom: 24 }}
>
{poll.comments?.map((comment) => (
<div key={comment.id} style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #252525' }}>
<Row justify="space-between">
<Col>
<Text strong>{comment.authorName}</Text>
<Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>
{dayjs(comment.createdAt).format('MMM D, h:mm A')}
</Text>
</Col>
</Row>
<Text style={{ display: 'block', marginTop: 4 }}>{comment.content}</Text>
</div>
))}
{poll.status !== 'CANCELLED' && (
<>
<Divider style={{ margin: '12px 0' }} />
<Row gutter={[8, 8]}>
<Col xs={24} sm={6}>
<Input
placeholder="Your name"
value={commentName}
onChange={(e) => setCommentName(e.target.value)}
size="small"
/>
</Col>
<Col xs={24} sm={14}>
<Input.TextArea
placeholder="Add a comment..."
value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}
rows={1}
size="small"
/>
</Col>
<Col xs={24} sm={4}>
<Button
type="primary"
size="small"
onClick={handleSubmitComment}
loading={commentSubmitting}
block
>
Post
</Button>
</Col>
</Row>
</>
)}
</Card>
</div>
);
}

View File

@ -0,0 +1,231 @@
import { useState, useEffect } from 'react';
import { Modal, Input, List, Tag, Typography, Space, Button, App } from 'antd';
import { SendOutlined, PhoneOutlined, UserOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { useDebounce } from '@/hooks/useDebounce';
import type { SmsContactSearchResult, SmsConversation } from '@/types/sms';
const { Text } = Typography;
const { TextArea } = Input;
const SOURCE_LABELS: Record<string, { label: string; color: string }> = {
sms_contact: { label: 'SMS List', color: 'purple' },
crm_contact: { label: 'CRM', color: 'blue' },
conversation: { label: 'Previous', color: 'default' },
};
interface Props {
open: boolean;
onClose: () => void;
onCreated: (conv: SmsConversation) => void;
}
export default function NewConversationModal({ open, onClose, onCreated }: Props) {
const { message } = App.useApp();
const [query, setQuery] = useState('');
const [results, setResults] = useState<SmsContactSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [selectedContact, setSelectedContact] = useState<SmsContactSearchResult | null>(null);
const [msgText, setMsgText] = useState('');
const [sending, setSending] = useState(false);
const debouncedQuery = useDebounce(query, 300);
// Search contacts when debounced query changes
useEffect(() => {
if (!debouncedQuery || debouncedQuery.length < 2) {
setResults([]);
return;
}
let cancelled = false;
setSearching(true);
api.get<{ results: SmsContactSearchResult[] }>('/sms/conversations/contact-search', {
params: { q: debouncedQuery },
}).then(({ data }) => {
if (!cancelled) setResults(data.results);
}).catch(() => {
if (!cancelled) setResults([]);
}).finally(() => {
if (!cancelled) setSearching(false);
});
return () => { cancelled = true; };
}, [debouncedQuery]);
// Check if query looks like a phone number (mostly digits, 7+)
const digitsOnly = query.replace(/\D/g, '');
const isPhoneQuery = digitsOnly.length >= 7;
const phoneAlreadyInResults = isPhoneQuery && results.some((r) => r.phone.includes(digitsOnly));
const handleSelect = (contact: SmsContactSearchResult) => {
setSelectedContact(contact);
};
const handleManualPhone = () => {
setSelectedContact({
phone: digitsOnly,
name: null,
source: 'sms_contact',
sourceId: '',
});
};
const handleSend = async () => {
if (!selectedContact || !msgText.trim()) return;
setSending(true);
try {
const { data } = await api.post<SmsConversation>('/sms/conversations', {
phone: selectedContact.phone,
message: msgText.trim(),
contactName: selectedContact.name || undefined,
contactId: selectedContact.contactId || undefined,
});
message.success('Message queued');
onCreated(data);
handleReset();
} catch (err: any) {
const errMsg = err.response?.data?.error || 'Failed to send message';
message.error(errMsg);
} finally {
setSending(false);
}
};
const handleReset = () => {
setQuery('');
setResults([]);
setSelectedContact(null);
setMsgText('');
onClose();
};
const handleBack = () => {
setSelectedContact(null);
setMsgText('');
};
return (
<Modal
title="New Conversation"
open={open}
onCancel={handleReset}
footer={null}
destroyOnHidden
width={480}
>
{!selectedContact ? (
// Step 1: Contact Search
<div>
<Input
placeholder="Search by name or phone number..."
prefix={<PhoneOutlined />}
value={query}
onChange={(e) => setQuery(e.target.value)}
allowClear
autoFocus
/>
<div style={{ marginTop: 12, maxHeight: 320, overflowY: 'auto' }}>
{/* Manual phone entry option */}
{isPhoneQuery && !phoneAlreadyInResults && (
<div
onClick={handleManualPhone}
style={{
padding: '8px 12px',
cursor: 'pointer',
borderRadius: 6,
marginBottom: 4,
border: '1px dashed rgba(255,255,255,0.15)',
}}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<Space>
<PhoneOutlined />
<Text>Use number: {digitsOnly}</Text>
<Tag color="green">Manual</Tag>
</Space>
</div>
)}
<List
loading={searching}
dataSource={results}
locale={{ emptyText: query.length >= 2 ? 'No contacts found' : 'Type to search...' }}
renderItem={(item) => (
<List.Item
onClick={() => handleSelect(item)}
style={{ cursor: 'pointer', padding: '8px 12px', borderRadius: 6 }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,0.06)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
>
<div style={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
<Space>
<UserOutlined />
<div>
<Text strong>{item.name || item.phone}</Text>
{item.name && <Text type="secondary" style={{ marginLeft: 8, fontSize: 12 }}>{item.phone}</Text>}
</div>
</Space>
<Tag color={SOURCE_LABELS[item.source]?.color}>
{SOURCE_LABELS[item.source]?.label}
</Tag>
</div>
</List.Item>
)}
/>
</div>
</div>
) : (
// Step 2: Compose Message
<div>
<Button type="link" onClick={handleBack} style={{ padding: 0, marginBottom: 8 }}>
&larr; Change contact
</Button>
<div style={{
padding: '10px 14px',
background: 'rgba(255,255,255,0.04)',
borderRadius: 8,
marginBottom: 12,
}}>
<Space>
<UserOutlined />
<div>
{selectedContact.name && <Text strong>{selectedContact.name}</Text>}
<Text type="secondary" style={{ marginLeft: selectedContact.name ? 8 : 0 }}>
{selectedContact.phone}
</Text>
</div>
</Space>
</div>
<TextArea
rows={4}
value={msgText}
onChange={(e) => setMsgText(e.target.value)}
placeholder="Type your message..."
maxLength={1600}
showCount
autoFocus
/>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 12 }}>
<Space>
<Button onClick={handleReset}>Cancel</Button>
<Button
type="primary"
icon={<SendOutlined />}
onClick={handleSend}
loading={sending}
disabled={!msgText.trim()}
>
Send
</Button>
</Space>
</div>
</div>
)}
</Modal>
);
}

View File

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm, Divider, Alert } from 'antd';
import { Table, Button, Drawer, Form, Input, Select, InputNumber, Space, Tag, Progress, App, Typography, Popconfirm, Divider, Alert } from 'antd';
import { PlusOutlined, PlayCircleOutlined, PauseCircleOutlined, CaretRightOutlined, DeleteOutlined, EyeOutlined, SendOutlined, PhoneOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
@ -26,9 +26,10 @@ export default function SmsCampaignsPage() {
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [contactLists, setContactLists] = useState<SmsContactList[]>([]);
const [createForm] = Form.useForm();
const [saving, setSaving] = useState(false);
// Preview & Test
const [testPreview, setTestPreview] = useState('');
@ -67,16 +68,21 @@ export default function SmsCampaignsPage() {
return () => clearInterval(interval);
}, [campaigns, fetchCampaigns]);
const closeDrawer = () => { setDrawerOpen(false); setTestPreview(''); };
const handleCreate = async (values: { name: string; messageTemplate: string; contactListId: string; delayBetweenMs: number }) => {
setSaving(true);
try {
await api.post('/sms/campaigns', values);
message.success('Campaign created');
setCreateOpen(false);
closeDrawer();
createForm.resetFields();
fetchCampaigns();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Create failed';
message.error(msg);
} finally {
setSaving(false);
}
};
@ -209,13 +215,16 @@ export default function SmsCampaignsPage() {
},
];
const drawerWidth = 480;
return (
<>
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
<Space style={{ marginBottom: 16 }}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => { setCreateOpen(true); fetchContactLists(); }}
onClick={() => { setDrawerOpen(true); fetchContactLists(); }}
>
New Campaign
</Button>
@ -229,14 +238,24 @@ export default function SmsCampaignsPage() {
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
size="middle"
/>
</div>
{/* Create Campaign Modal */}
<Modal
<Drawer
title="New SMS Campaign"
open={createOpen}
onCancel={() => { setCreateOpen(false); setTestPreview(''); }}
onOk={() => createForm.submit()}
width={600}
open={drawerOpen}
onClose={closeDrawer}
destroyOnHidden
mask={false}
width={drawerWidth}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={closeDrawer}>Cancel</Button>
<Button type="primary" loading={saving} onClick={() => createForm.submit()}>
Create
</Button>
</Space>
}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ delayBetweenMs: 3000 }}>
<Form.Item name="name" label="Campaign Name" rules={[{ required: true }]}>
@ -306,7 +325,7 @@ export default function SmsCampaignsPage() {
<InputNumber min={1000} max={60000} step={500} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</Drawer>
</>
);
}

View File

@ -1,11 +1,12 @@
import { useState, useEffect, useCallback } from 'react';
import { Table, Button, Modal, Form, Input, Space, Drawer, Upload, App, Typography, Popconfirm, Tabs, Select } from 'antd';
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined } from '@ant-design/icons';
import { Table, Button, Drawer, Form, Input, Space, Upload, App, Typography, Popconfirm, Tabs, Select, Collapse, Tag, Modal } from 'antd';
import { PlusOutlined, UploadOutlined, PhoneOutlined, DeleteOutlined, ImportOutlined, DatabaseOutlined, UserOutlined, EnvironmentOutlined, CalendarOutlined, MessageOutlined, ContactsOutlined, UnorderedListOutlined, UserAddOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { SmsContactList, SmsContactListEntry, SmsPaginatedResponse } from '@/types/sms';
import type { AppOutletContext } from '@/types/api';
import { useOutletContext } from 'react-router-dom';
import { useDebounce } from '@/hooks/useDebounce';
const { Text } = Typography;
const { TextArea } = Input;
@ -19,23 +20,39 @@ export default function SmsContactsPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const [lists, setLists] = useState<SmsContactList[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [createOpen, setCreateOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importListId, setImportListId] = useState<string | null>(null);
const [csvText, setCsvText] = useState('');
const [drawerOpen, setDrawerOpen] = useState(false);
const [selectedList, setSelectedList] = useState<SmsContactList | null>(null);
// --- Contacts (entries) state ---
const [entries, setEntries] = useState<SmsContactListEntry[]>([]);
const [entriesTotal, setEntriesTotal] = useState(0);
const [entriesPage, setEntriesPage] = useState(1);
const [entriesLoading, setEntriesLoading] = useState(false);
const [createForm] = Form.useForm();
const [entriesLoading, setEntriesLoading] = useState(true);
const [searchText, setSearchText] = useState('');
const debouncedSearch = useDebounce(searchText, 300);
const [filterListId, setFilterListId] = useState<string | undefined>();
// Import modal state
// --- Lists state ---
const [lists, setLists] = useState<SmsContactList[]>([]);
const [listsTotal, setListsTotal] = useState(0);
const [listsPage, setListsPage] = useState(1);
const [listsLoading, setListsLoading] = useState(true);
// --- Drawers ---
const [createOpen, setCreateOpen] = useState(false);
const [addContactOpen, setAddContactOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importListId, setImportListId] = useState<string | null>(null);
const [csvText, setCsvText] = useState('');
const [createForm] = Form.useForm();
const [addContactForm] = Form.useForm();
const [createSaving, setCreateSaving] = useState(false);
const [addContactSaving, setAddContactSaving] = useState(false);
// --- Selection + bulk actions ---
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
const [addToListModalOpen, setAddToListModalOpen] = useState(false);
const [addToListTargetId, setAddToListTargetId] = useState<string | undefined>();
const [addToListLoading, setAddToListLoading] = useState(false);
// Import state
const [preview, setPreview] = useState<PreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [importLoading, setImportLoading] = useState(false);
@ -55,35 +72,47 @@ export default function SmsContactsPage() {
const [smsCampaigns, setSmsCampaigns] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contact lists for SMS campaigns' });
setPageHeader({ title: 'SMS Contacts', subtitle: 'Manage contacts across all lists' });
}, [setPageHeader]);
const fetchLists = useCallback(async () => {
setLoading(true);
try {
const { data } = await api.get<SmsPaginatedResponse<SmsContactList>>('/sms/contacts', { params: { page, limit: 50 } });
setLists(data.items);
setTotal(data.total);
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => { fetchLists(); }, [fetchLists]);
const fetchEntries = useCallback(async (listId: string, p = 1) => {
// --- Fetch all entries (contacts-first view) ---
const fetchEntries = useCallback(async (p = 1) => {
setEntriesLoading(true);
try {
const { data } = await api.get<SmsPaginatedResponse<SmsContactListEntry>>(`/sms/contacts/${listId}/entries`, { params: { page: p, limit: 100 } });
const params: Record<string, string | number> = { page: p, limit: 50 };
if (filterListId) params.listId = filterListId;
if (debouncedSearch) params.search = debouncedSearch;
const { data } = await api.get<SmsPaginatedResponse<SmsContactListEntry>>('/sms/contacts/all-entries', { params });
setEntries(data.items);
setEntriesTotal(data.total);
setEntriesPage(p);
} finally {
setEntriesLoading(false);
}
}, [filterListId, debouncedSearch]);
// --- Fetch lists (for dropdown + lists panel) ---
const fetchLists = useCallback(async (p = 1) => {
setListsLoading(true);
try {
const { data } = await api.get<SmsPaginatedResponse<SmsContactList>>('/sms/contacts', { params: { page: p, limit: 50 } });
setLists(data.items);
setListsTotal(data.total);
setListsPage(p);
} finally {
setListsLoading(false);
}
}, []);
// Load shifts and SMS campaigns for filter dropdowns when import modal opens
useEffect(() => { fetchLists(); }, [fetchLists]);
// Re-fetch entries when filters or page change
useEffect(() => {
setEntriesPage(1);
fetchEntries(1);
}, [fetchEntries]);
// Load shifts and SMS campaigns for filter dropdowns when import drawer opens
const loadFilterOptions = useCallback(async () => {
try {
const [shiftsRes, campaignsRes] = await Promise.all([
@ -97,15 +126,71 @@ export default function SmsContactsPage() {
}
}, []);
const closeCreateDrawer = () => { setCreateOpen(false); };
const closeAddContactDrawer = () => { setAddContactOpen(false); };
const closeImportDrawer = () => { setImportOpen(false); resetImportState(); };
const handleCreate = async (values: { name: string }) => {
setCreateSaving(true);
try {
await api.post('/sms/contacts', values);
const { data } = await api.post('/sms/contacts', values);
message.success('Contact list created');
setCreateOpen(false);
closeCreateDrawer();
createForm.resetFields();
fetchLists();
// Auto-select the new list in the filter
setFilterListId(data.id);
} catch {
message.error('Failed to create list');
} finally {
setCreateSaving(false);
}
};
const handleAddContact = async (values: { listId: string; phone: string; name?: string; email?: string }) => {
setAddContactSaving(true);
try {
const { listId, ...body } = values;
await api.post(`/sms/contacts/${listId}/entries`, body);
message.success('Contact added');
closeAddContactDrawer();
addContactForm.resetFields();
fetchEntries(entriesPage);
fetchLists();
} catch {
message.error('Failed to add contact');
} finally {
setAddContactSaving(false);
}
};
const openAddContactDrawer = () => {
// Pre-fill the list if one is selected in the filter
if (filterListId) {
addContactForm.setFieldsValue({ listId: filterListId });
} else if (lists.length === 1) {
addContactForm.setFieldsValue({ listId: lists[0]!.id });
}
setAddContactOpen(true);
};
const handleAddToList = async () => {
if (!addToListTargetId || selectedRowKeys.length === 0) return;
setAddToListLoading(true);
try {
const selected = entries.filter(e => selectedRowKeys.includes(e.id));
const payload = selected.map(e => ({ phone: e.phone, name: e.name || undefined, email: e.email || undefined }));
const { data } = await api.post(`/sms/contacts/${addToListTargetId}/entries/bulk`, { entries: payload });
message.success(`Added ${data.imported} contact${data.imported !== 1 ? 's' : ''} to list${data.skipped ? ` (${data.skipped} skipped)` : ''}`);
setAddToListModalOpen(false);
setAddToListTargetId(undefined);
setSelectedRowKeys([]);
fetchEntries(entriesPage);
fetchLists();
} catch {
message.error('Failed to add contacts to list');
} finally {
setAddToListLoading(false);
}
};
@ -113,7 +198,10 @@ export default function SmsContactsPage() {
try {
await api.delete(`/sms/contacts/${id}`);
message.success('List archived');
// If we're filtering by this list, clear the filter
if (filterListId === id) setFilterListId(undefined);
fetchLists();
fetchEntries(1);
} catch {
message.error('Failed to archive list');
}
@ -125,10 +213,9 @@ export default function SmsContactsPage() {
try {
const { data } = await api.post(`/sms/contacts/${importListId}/import-csv`, { csv: csvText });
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
setImportOpen(false);
resetImportState();
closeImportDrawer();
fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId);
fetchEntries(entriesPage);
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Import failed';
message.error(msg);
@ -143,10 +230,9 @@ export default function SmsContactsPage() {
try {
const { data } = await api.post(`/sms/contacts/${importListId}/import-phone`);
message.success(`Imported ${data.imported} contacts from phone`);
setImportOpen(false);
resetImportState();
closeImportDrawer();
fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId);
fetchEntries(entriesPage);
} catch {
message.error('Failed to import from phone');
} finally {
@ -154,26 +240,28 @@ export default function SmsContactsPage() {
}
};
const handleDeleteEntry = async (entryId: string) => {
if (!selectedList) return;
const handleDeleteEntry = async (entry: SmsContactListEntry) => {
try {
await api.delete(`/sms/contacts/${selectedList.id}/entries/${entryId}`);
message.success('Entry removed');
fetchEntries(selectedList.id, entriesPage);
await api.delete(`/sms/contacts/${entry.listId}/entries/${entry.id}`);
message.success('Contact removed');
fetchEntries(entriesPage);
fetchLists();
} catch {
message.error('Failed to remove entry');
message.error('Failed to remove contact');
}
};
const openDrawer = (list: SmsContactList) => {
setSelectedList(list);
setDrawerOpen(true);
fetchEntries(list.id);
};
const openImportModal = (listId: string) => {
const openImportDrawer = (listId?: string) => {
if (listId) {
setImportListId(listId);
} else if (filterListId) {
setImportListId(filterListId);
} else if (lists.length === 1) {
setImportListId(lists[0]!.id);
} else {
message.info('Select a list first, or use the filter dropdown to choose one');
return;
}
setImportOpen(true);
loadFilterOptions();
};
@ -251,10 +339,9 @@ export default function SmsContactsPage() {
}
const { data } = await api.post(`/sms/contacts/${importListId}/import-${source}`, body);
message.success(`Imported ${data.imported} contacts (${data.skipped} skipped)`);
setImportOpen(false);
resetImportState();
closeImportDrawer();
fetchLists();
if (selectedList?.id === importListId) fetchEntries(importListId);
fetchEntries(entriesPage);
} catch {
message.error('Import failed');
} finally {
@ -298,30 +385,75 @@ export default function SmsContactsPage() {
</div>
);
const columns: ColumnsType<SmsContactList> = [
// --- Main contacts table columns ---
const contactColumns: ColumnsType<SmsContactListEntry> = [
{ title: 'Phone', dataIndex: 'phone', width: 140 },
{ title: 'Name', dataIndex: 'name', render: (v) => v || <Text type="secondary">-</Text> },
{ title: 'Email', dataIndex: 'email', render: (v) => v || <Text type="secondary">-</Text>, responsive: ['md'] },
{
title: 'List',
dataIndex: ['list', 'name'],
width: 180,
render: (name: string, record) => {
if (!record.list) return <Text type="secondary">-</Text>;
return (
<Tag
color="blue"
style={{ cursor: 'pointer' }}
onClick={() => setFilterListId(record.list!.id)}
>
{name}
</Tag>
);
},
},
{
title: 'Added',
dataIndex: 'createdAt',
width: 110,
render: (d) => new Date(d).toLocaleDateString(),
responsive: ['lg'],
},
{
title: '',
width: 40,
render: (_, record) => (
<Popconfirm title="Remove this contact?" onConfirm={() => handleDeleteEntry(record)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
// --- Lists panel columns ---
const listColumns: ColumnsType<SmsContactList> = [
{
title: 'Name',
dataIndex: 'name',
render: (name, record) => <a onClick={() => openDrawer(record)}>{name}</a>,
render: (name, record) => (
<a onClick={() => setFilterListId(record.id)}>{name}</a>
),
},
{ title: 'Contacts', dataIndex: 'totalContacts', width: 100 },
{ title: 'Contacts', dataIndex: 'totalContacts', width: 90 },
{
title: 'Source',
dataIndex: 'originalFilename',
render: (f) => f || <Text type="secondary">Manual</Text>,
responsive: ['md'],
},
{
title: 'Created',
dataIndex: 'createdAt',
render: (d) => new Date(d).toLocaleDateString(),
width: 120,
width: 110,
responsive: ['lg'],
},
{
title: 'Actions',
width: 160,
render: (_, record) => (
<Space>
<Button size="small" icon={<DatabaseOutlined />} onClick={() => openImportModal(record.id)}>Import</Button>
<Button size="small" icon={<DatabaseOutlined />} onClick={() => openImportDrawer(record.id)}>Import</Button>
<Popconfirm title="Archive this list?" onConfirm={() => handleArchive(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
@ -330,21 +462,6 @@ export default function SmsContactsPage() {
},
];
const entryColumns: ColumnsType<SmsContactListEntry> = [
{ title: 'Phone', dataIndex: 'phone', width: 140 },
{ title: 'Name', dataIndex: 'name', render: (v) => v || <Text type="secondary">-</Text> },
{ title: 'Email', dataIndex: 'email', render: (v) => v || <Text type="secondary">-</Text> },
{
title: '',
width: 40,
render: (_, record) => (
<Popconfirm title="Remove?" onConfirm={() => handleDeleteEntry(record.id)}>
<Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
),
},
];
const supportLevelOptions = [
{ value: 'LEVEL_1', label: 'Level 1 (Strong Support)' },
{ value: 'LEVEL_2', label: 'Level 2 (Leaning Support)' },
@ -367,43 +484,211 @@ export default function SmsContactsPage() {
{ value: 'MAP_ADMIN', label: 'Map Admin' },
];
const importListName = importListId ? lists.find(l => l.id === importListId)?.name : undefined;
// Either drawer being open shifts content
const anyDrawerOpen = createOpen || addContactOpen || importOpen;
const drawerWidth = 480;
const importDrawerWidth = 560;
const activeDrawerWidth = importOpen ? importDrawerWidth : drawerWidth;
return (
<>
<Space style={{ marginBottom: 16 }}>
<div style={{ marginRight: anyDrawerOpen ? activeDrawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
{/* Top toolbar */}
<Space wrap style={{ marginBottom: 16, width: '100%' }}>
<Input.Search
placeholder="Search phone, name, or email..."
allowClear
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
style={{ width: 260 }}
/>
<Select
placeholder="All Lists"
allowClear
showSearch
optionFilterProp="label"
style={{ width: 200 }}
value={filterListId}
onChange={(v) => setFilterListId(v)}
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>New List</Button>
<Button icon={<UserAddOutlined />} onClick={openAddContactDrawer}>Add Contact</Button>
<Button icon={<ImportOutlined />} onClick={() => openImportDrawer()}>Import</Button>
</Space>
{/* Lists management panel */}
<Collapse
style={{ marginBottom: 16 }}
items={[{
key: 'lists',
label: <span><UnorderedListOutlined /> Manage Lists ({listsTotal})</span>,
children: (
<Table
rowKey="id"
columns={columns}
columns={listColumns}
dataSource={lists}
loading={loading}
pagination={{ current: page, total, pageSize: 50, onChange: setPage }}
size="middle"
loading={listsLoading}
pagination={{
current: listsPage,
total: listsTotal,
pageSize: 50,
onChange: (p) => fetchLists(p),
showSizeChanger: false,
}}
size="small"
/>
),
}]}
/>
{/* Create List Modal */}
<Modal
{/* Selection action bar */}
{selectedRowKeys.length > 0 && (
<div style={{
marginBottom: 12,
padding: '8px 16px',
background: 'rgba(22, 119, 255, 0.08)',
borderRadius: 8,
display: 'flex',
alignItems: 'center',
gap: 12,
}}>
<Text strong>{selectedRowKeys.length} selected</Text>
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => setAddToListModalOpen(true)}
>
Add to List
</Button>
<Button size="small" type="link" onClick={() => setSelectedRowKeys([])}>
Clear
</Button>
</div>
)}
{/* Main contacts table */}
<Table
rowKey="id"
columns={contactColumns}
dataSource={entries}
loading={entriesLoading}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
preserveSelectedRowKeys: true,
}}
pagination={{
current: entriesPage,
total: entriesTotal,
pageSize: 50,
onChange: (p) => fetchEntries(p),
showTotal: (t) => `${t} contacts`,
showSizeChanger: false,
}}
size="middle"
/>
</div>
{/* Create List Drawer */}
<Drawer
title="New Contact List"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
onClose={closeCreateDrawer}
destroyOnHidden
mask={false}
width={drawerWidth}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={closeCreateDrawer}>Cancel</Button>
<Button type="primary" loading={createSaving} onClick={() => createForm.submit()}>
Create
</Button>
</Space>
}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
<Form.Item name="name" label="List Name" rules={[{ required: true }]}>
<Input placeholder="e.g. Ward 6 Supporters" />
</Form.Item>
</Form>
</Drawer>
{/* Add Single Contact Drawer */}
<Drawer
title="Add Contact"
open={addContactOpen}
onClose={closeAddContactDrawer}
destroyOnHidden
mask={false}
width={drawerWidth}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={closeAddContactDrawer}>Cancel</Button>
<Button type="primary" loading={addContactSaving} onClick={() => addContactForm.submit()}>
Add
</Button>
</Space>
}
>
<Form form={addContactForm} layout="vertical" onFinish={handleAddContact}>
<Form.Item name="listId" label="List" rules={[{ required: true, message: 'Select a contact list' }]}>
<Select
placeholder="Select a list"
showSearch
optionFilterProp="label"
options={lists.map(l => ({ value: l.id, label: l.name }))}
/>
</Form.Item>
<Form.Item name="phone" label="Phone Number" rules={[{ required: true, message: 'Phone number is required' }]}>
<Input placeholder="e.g. 5551234567" />
</Form.Item>
<Form.Item name="name" label="Name">
<Input placeholder="e.g. Jane Doe" />
</Form.Item>
<Form.Item name="email" label="Email">
<Input placeholder="e.g. jane@example.com" type="email" />
</Form.Item>
</Form>
</Drawer>
{/* Add to List Modal */}
<Modal
title={`Add ${selectedRowKeys.length} contact${selectedRowKeys.length !== 1 ? 's' : ''} to list`}
open={addToListModalOpen}
onCancel={() => { setAddToListModalOpen(false); setAddToListTargetId(undefined); }}
onOk={handleAddToList}
okText="Add to List"
confirmLoading={addToListLoading}
okButtonProps={{ disabled: !addToListTargetId }}
>
<Select
placeholder="Select target list"
showSearch
optionFilterProp="label"
style={{ width: '100%', marginTop: 8 }}
value={addToListTargetId}
onChange={setAddToListTargetId}
options={lists.map(l => ({ value: l.id, label: `${l.name} (${l.totalContacts})` }))}
/>
</Modal>
{/* Unified Import Modal */}
<Modal
title="Import Contacts"
{/* Import Contacts Drawer */}
<Drawer
title={`Import Contacts${importListName ? `${importListName}` : ''}`}
open={importOpen}
onCancel={() => { setImportOpen(false); resetImportState(); }}
footer={null}
width={640}
destroyOnClose
onClose={closeImportDrawer}
destroyOnHidden
mask={false}
width={importDrawerWidth}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Button onClick={closeImportDrawer}>Close</Button>
}
>
<Tabs
onChange={() => setPreview(null)}
@ -599,28 +884,6 @@ export default function SmsContactsPage() {
},
]}
/>
</Modal>
{/* Entries Drawer */}
<Drawer
title={selectedList ? `${selectedList.name} (${selectedList.totalContacts} contacts)` : 'Entries'}
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={600}
>
<Table
rowKey="id"
columns={entryColumns}
dataSource={entries}
loading={entriesLoading}
pagination={{
current: entriesPage,
total: entriesTotal,
pageSize: 100,
onChange: (p) => selectedList && fetchEntries(selectedList.id, p),
}}
size="small"
/>
</Drawer>
</>
);

View File

@ -1,10 +1,11 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Row, Col, List, Card, Input, Button, Tag, Badge, Space, Typography, Empty, Spin, App, Checkbox, Select, Collapse } from 'antd';
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined } from '@ant-design/icons';
import { SendOutlined, CheckOutlined, ReadOutlined, CloseCircleOutlined, LinkOutlined, PlusOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { SmsConversation, SmsPaginatedResponse } from '@/types/sms';
import type { AppOutletContext } from '@/types/api';
import { useOutletContext, Link } from 'react-router-dom';
import NewConversationModal from './NewConversationModal';
const { Text, Paragraph } = Typography;
const { Search, TextArea } = Input;
@ -37,6 +38,9 @@ export default function SmsConversationsPage() {
const [notesSaving, setNotesSaving] = useState(false);
const [tagsSaving, setTagsSaving] = useState(false);
// New conversation modal
const [newConvOpen, setNewConvOpen] = useState(false);
// Bulk selection state
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkLoading, setBulkLoading] = useState(false);
@ -179,12 +183,17 @@ export default function SmsConversationsPage() {
<Row gutter={16} style={{ height: 'calc(100vh - 200px)', minHeight: 400 }}>
{/* Conversation List (left panel) */}
<Col xs={24} md={8} style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Search
placeholder="Search by phone or name..."
onSearch={(v) => { setSearch(v); fetchConversations(v); }}
allowClear
style={{ marginBottom: 8 }}
style={{ flex: 1 }}
/>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNewConvOpen(true)}>
New
</Button>
</div>
{/* Bulk action bar */}
{selectedIds.size > 0 && (
@ -396,6 +405,15 @@ export default function SmsConversationsPage() {
</Card>
)}
</Col>
<NewConversationModal
open={newConvOpen}
onClose={() => setNewConvOpen(false)}
onCreated={(conv) => {
fetchConversations();
if (conv) setSelected(conv);
}}
/>
</Row>
);
}

View File

@ -471,7 +471,7 @@ export default function SmsSetupPage() {
<li>Save the API key to ~/.bashrc and ~/.sms-api-key</li>
<li>Request SMS and Contacts permissions (tap <Text strong>Allow</Text>)</li>
<li>Set up Termux:Boot auto-start (if installed)</li>
<li>Start the server with the watchdog</li>
<li>Install runit service supervisor and start the server</li>
</ul>
<Paragraph style={{ marginTop: 8 }}>
When done, note the <Text strong>Phone URL</Text> displayed (e.g. <Text code>http://100.x.x.x:5001</Text>) — you'll need it in the next step.
@ -514,10 +514,10 @@ export default function SmsSetupPage() {
<div>
<Paragraph style={{ marginBottom: 4 }}>If the server is already running but the API key needs updating, run this on the phone:</Paragraph>
<div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}>
<CmdLine comment="Update key, restart server" cmd={`export SMS_API_SECRET="${generatedKey}" && sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && pkill -f termux-sms-api-server.py && sleep 2 && cd ~/sms-server/android && bash sms-watchdog.sh`} />
<CmdLine comment="Update key and restart service" cmd={`sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && source ~/.bashrc && sv restart sms-api`} />
</div>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
Or pull latest code and re-run full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup.sh ${generatedKey}` }}>cd ~/sms-server && git pull && bash android/setup.sh {generatedKey.substring(0, 8)}...</Text>
If <Text code>sv</Text> is not installed yet, run the full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup-services.sh` }}>cd ~/sms-server && git pull && bash android/setup-services.sh</Text>
</Paragraph>
</div>
}
@ -898,7 +898,7 @@ export default function SmsSetupPage() {
The Termux API server is not responding. This can mean:
</Paragraph>
<ul style={{ marginLeft: 20, marginBottom: 8 }}>
<li><Text strong>Server not running</Text> Android may have killed the Termux process. Restart it on the phone: <Text code>cd ~/sms-server/android && bash sms-watchdog.sh</Text></li>
<li><Text strong>Server not running</Text> Android may have killed the Termux process. Open Termux on the phone and check: <Text code>sv status sms-api</Text>. If down, run: <Text code>sv up sms-api</Text></li>
<li><Text strong>API key mismatch</Text> the key saved here doesn't match the phone's <Text code>SMS_API_SECRET</Text>. Click Reconfigure to generate a new key and update both sides.</li>
<li><Text strong>Network issue</Text> the phone may not be reachable. Check Tailscale is connected on the phone.</li>
</ul>

View File

@ -0,0 +1,408 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Button, Modal, Drawer, Form, Input, Select, Space, Tag, App, Typography, Switch, Tooltip } from 'antd';
import { PlusOutlined, EditOutlined, CopyOutlined, DeleteOutlined, StarFilled, StarOutlined } from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import { api } from '@/lib/api';
import type { SmsMessageTemplate, SmsPaginatedResponse } from '@/types/sms';
import type { AppOutletContext } from '@/types/api';
import { useOutletContext } from 'react-router-dom';
import { useDebounce } from '@/hooks/useDebounce';
const { TextArea } = Input;
const { Text } = Typography;
const CATEGORY_COLORS: Record<string, string> = {
notification: 'blue',
campaign: 'purple',
custom: 'green',
};
/** Known placeholder sample values for live preview */
const SAMPLE_VALUES: Record<string, string> = {
name: 'Jane Doe',
phone: '+1 555 000 0000',
shiftTitle: 'Ward 6 Canvass',
shiftTime: '2:00 PM',
shiftDate: 'Mar 15',
shiftLocation: 'Community Centre',
organizationName: 'Changemaker',
};
/** Extract {var} names from a template string */
function extractVars(template: string): string[] {
const vars: string[] = [];
const regex = /\{(\w+)\}/g;
let m;
while ((m = regex.exec(template)) !== null) {
const v = m[1] as string;
if (!vars.includes(v)) vars.push(v);
}
return vars;
}
/** Calculate SMS segment count */
function segmentCount(length: number): number {
if (length === 0) return 0;
if (length <= 160) return 1;
return Math.ceil(length / 153);
}
/** Render a live preview with sample substitutions */
function renderPreview(template: string): string {
return template.replace(/\{(\w+)\}/g, (match, key) => SAMPLE_VALUES[key] ?? match);
}
export default function SmsTemplatesPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { message } = App.useApp();
const [templates, setTemplates] = useState<SmsMessageTemplate[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
// Filters
const [search, setSearch] = useState('');
const debouncedSearch = useDebounce(search, 300);
const [categoryFilter, setCategoryFilter] = useState<string | undefined>();
const [favoritesOnly, setFavoritesOnly] = useState(false);
// Drawer
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
// Live template text for character counter + variable extraction
const [liveTemplate, setLiveTemplate] = useState('');
useEffect(() => {
setPageHeader({ title: 'SMS Templates', subtitle: 'Manage reusable SMS message templates' });
}, [setPageHeader]);
const fetchTemplates = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, string | number> = { page, limit: 50 };
if (debouncedSearch) params.search = debouncedSearch;
if (categoryFilter) params.category = categoryFilter;
if (favoritesOnly) params.isFavorite = 'true';
const { data } = await api.get<SmsPaginatedResponse<SmsMessageTemplate>>('/sms/templates', { params });
setTemplates(data.items);
setTotal(data.total);
} finally {
setLoading(false);
}
}, [page, debouncedSearch, categoryFilter, favoritesOnly]);
useEffect(() => { fetchTemplates(); }, [fetchTemplates]);
// Reset to page 1 when filters change
useEffect(() => { setPage(1); }, [debouncedSearch, categoryFilter, favoritesOnly]);
const closeDrawer = () => { setDrawerOpen(false); setLiveTemplate(''); };
const openCreate = () => {
setEditingId(null);
form.resetFields();
setLiveTemplate('');
setDrawerOpen(true);
};
const openEdit = (record: SmsMessageTemplate) => {
setEditingId(record.id);
form.setFieldsValue({
name: record.name,
template: record.template,
description: record.description || '',
category: record.category || undefined,
});
setLiveTemplate(record.template);
setDrawerOpen(true);
};
const openDuplicate = (record: SmsMessageTemplate) => {
setEditingId(null);
form.setFieldsValue({
name: `${record.name} (copy)`,
template: record.template,
description: record.description || '',
category: record.category || undefined,
});
setLiveTemplate(record.template);
setDrawerOpen(true);
};
const handleSave = async (values: { name: string; template: string; description?: string; category?: string }) => {
setSaving(true);
try {
if (editingId) {
await api.put(`/sms/templates/${editingId}`, values);
message.success('Template updated');
} else {
await api.post('/sms/templates', values);
message.success('Template created');
}
closeDrawer();
form.resetFields();
fetchTemplates();
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Save failed';
message.error(msg);
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/sms/templates/${id}`);
message.success('Template deleted');
fetchTemplates();
} catch {
message.error('Delete failed — system templates cannot be deleted');
}
};
const handleToggleFavorite = async (id: string) => {
try {
await api.post(`/sms/templates/${id}/favorite`);
fetchTemplates();
} catch {
message.error('Failed to toggle favorite');
}
};
// Computed values for drawer
const liveVars = useMemo(() => extractVars(liveTemplate), [liveTemplate]);
const livePreview = useMemo(() => renderPreview(liveTemplate), [liveTemplate]);
const charCount = liveTemplate.length;
const segments = segmentCount(charCount);
const columns: ColumnsType<SmsMessageTemplate> = [
{
title: 'Name',
dataIndex: 'name',
ellipsis: true,
render: (name, record) => (
<Space>
<Tooltip title={record.isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
<span style={{ cursor: 'pointer' }} onClick={() => handleToggleFavorite(record.id)}>
{record.isFavorite ? <StarFilled style={{ color: '#faad14' }} /> : <StarOutlined style={{ color: 'rgba(255,255,255,0.3)' }} />}
</span>
</Tooltip>
<span>{name}</span>
{record.isSystem && <Tag color="geekblue">SYSTEM</Tag>}
</Space>
),
},
{
title: 'Category',
dataIndex: 'category',
width: 120,
render: (cat) => cat ? <Tag color={CATEGORY_COLORS[cat] || 'default'}>{cat}</Tag> : <Text type="secondary">-</Text>,
},
{
title: 'Variables',
width: 200,
render: (_, record) => (
<Space wrap size={2}>
{(record.variables || []).map((v) => (
<Tag key={v} style={{ fontSize: 11 }}>{`{${v}}`}</Tag>
))}
{(!record.variables || record.variables.length === 0) && <Text type="secondary">-</Text>}
</Space>
),
},
{
title: 'Uses',
dataIndex: 'usageCount',
width: 70,
align: 'center',
},
{
title: 'Updated',
dataIndex: 'updatedAt',
width: 100,
render: (d) => {
const diff = Date.now() - new Date(d).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
},
},
{
title: 'Actions',
width: 140,
render: (_, record) => (
<Space>
<Tooltip title="Edit">
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Tooltip title="Duplicate">
<Button size="small" icon={<CopyOutlined />} onClick={() => openDuplicate(record)} />
</Tooltip>
{!record.isSystem && (
<Tooltip title="Delete">
<Button
size="small"
danger
icon={<DeleteOutlined />}
onClick={() => {
Modal.confirm({
title: 'Delete template?',
content: `This will permanently delete "${record.name}".`,
okText: 'Delete',
okType: 'danger',
onOk: () => handleDelete(record.id),
});
}}
/>
</Tooltip>
)}
</Space>
),
},
];
const drawerWidth = 480;
return (
<>
<div style={{ marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
<Space style={{ marginBottom: 16 }} wrap>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
New Template
</Button>
<Input.Search
placeholder="Search templates..."
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
style={{ width: 220 }}
/>
<Select
placeholder="Category"
value={categoryFilter}
onChange={setCategoryFilter}
allowClear
style={{ width: 140 }}
options={[
{ value: 'notification', label: 'Notification' },
{ value: 'campaign', label: 'Campaign' },
{ value: 'custom', label: 'Custom' },
]}
/>
<Space>
<Switch size="small" checked={favoritesOnly} onChange={setFavoritesOnly} />
<Text type="secondary" style={{ fontSize: 12 }}>Favorites only</Text>
</Space>
</Space>
<Table
rowKey="id"
columns={columns}
dataSource={templates}
loading={loading}
pagination={{ current: page, total, pageSize: 50, onChange: setPage, showSizeChanger: false }}
size="middle"
/>
</div>
<Drawer
title={editingId ? 'Edit Template' : 'New Template'}
open={drawerOpen}
onClose={closeDrawer}
destroyOnHidden
mask={false}
width={drawerWidth}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={closeDrawer}>Cancel</Button>
<Button type="primary" loading={saving} onClick={() => form.submit()}>
{editingId ? 'Save' : 'Create'}
</Button>
</Space>
}
>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Template name is required' }]}>
<Input placeholder="e.g. shift-reminder-custom" maxLength={200} />
</Form.Item>
<Form.Item
name="template"
label="Message Template"
rules={[{ required: true, message: 'Template body is required' }]}
extra={
<Space style={{ marginTop: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{charCount} / 1,600 chars
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{segments} SMS segment{segments !== 1 ? 's' : ''}
</Text>
{charCount > 160 && (
<Text type="warning" style={{ fontSize: 12 }}>
Multi-part message (153 chars/segment)
</Text>
)}
</Space>
}
>
<TextArea
rows={4}
maxLength={1600}
placeholder="Hi {name}, your shift {shiftTitle} is coming up on {shiftDate} at {shiftTime}."
onChange={(e) => setLiveTemplate(e.target.value)}
/>
</Form.Item>
{/* Live variables */}
{liveVars.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>Detected variables: </Text>
{liveVars.map((v) => (
<Tag key={v} color="blue" style={{ fontSize: 11 }}>{`{${v}}`}</Tag>
))}
</div>
)}
{/* Live preview */}
{liveTemplate && (
<div style={{
background: 'rgba(255,255,255,0.05)',
padding: 12,
borderRadius: 8,
marginBottom: 16,
border: '1px solid rgba(255,255,255,0.1)',
}}>
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>Preview:</Text>
<Text style={{ fontSize: 13, fontFamily: 'monospace', whiteSpace: 'pre-wrap' }}>
{livePreview}
</Text>
</div>
)}
<Form.Item name="description" label="Description">
<TextArea rows={2} maxLength={500} placeholder="Internal notes about when to use this template" />
</Form.Item>
<Form.Item name="category" label="Category">
<Select
placeholder="Select category"
allowClear
options={[
{ value: 'notification', label: 'Notification' },
{ value: 'campaign', label: 'Campaign' },
{ value: 'custom', label: 'Custom' },
]}
/>
</Form.Item>
</Form>
</Drawer>
</>
);
}

View File

@ -1149,6 +1149,7 @@ export interface SiteSettings {
enablePeople: boolean;
enableSocial: boolean;
enableMeet: boolean;
enableMeetingPlanner: boolean;
autoSyncPeopleToMap: boolean;
// SMS connection config (only present from admin endpoint)
smsTermuxApiUrl?: string;
@ -2252,7 +2253,7 @@ export interface DashboardRecentSignupsResult {
export interface UnifiedCalendarItem {
id: string;
type: 'shift' | 'event';
type: 'shift' | 'event' | 'poll';
title: string;
date: string;
startTime: string;
@ -2264,6 +2265,10 @@ export interface UnifiedCalendarItem {
currentVolunteers?: number;
gancioEventId?: number;
gancioUrl?: string;
pollId?: string;
pollSlug?: string;
pollStatus?: SchedulingPollStatus;
pollVoteCount?: number;
}
export interface UnifiedCalendarResponse {
@ -2740,3 +2745,111 @@ export interface ListmonkCampaignsData {
error?: string;
}
// --- Scheduling Polls (Meeting Planner) ---
export type SchedulingPollStatus = 'OPEN' | 'CLOSED' | 'FINALIZED' | 'CANCELLED';
export type PollVoteValue = 'YES' | 'IF_NEED_BE' | 'NO';
export const POLL_STATUS_COLORS: Record<SchedulingPollStatus, string> = {
OPEN: 'green',
CLOSED: 'orange',
FINALIZED: 'blue',
CANCELLED: 'red',
};
export const POLL_STATUS_LABELS: Record<SchedulingPollStatus, string> = {
OPEN: 'Open',
CLOSED: 'Closed',
FINALIZED: 'Finalized',
CANCELLED: 'Cancelled',
};
export const VOTE_VALUE_COLORS: Record<PollVoteValue, string> = {
YES: '#52c41a',
IF_NEED_BE: '#faad14',
NO: '#d9d9d9',
};
export const VOTE_VALUE_LABELS: Record<PollVoteValue, string> = {
YES: 'Yes',
IF_NEED_BE: 'If Need Be',
NO: 'No',
};
export interface SchedulingPollOption {
id: string;
pollId: string;
date: string; // YYYY-MM-DD
startTime: string; // HH:MM
endTime: string; // HH:MM
sortOrder: number;
votes?: SchedulingPollVote[];
_count?: { votes: number };
// Aggregated vote counts (from API)
yesCount?: number;
ifNeedBeCount?: number;
noCount?: number;
score?: number;
}
export interface SchedulingPollVote {
id: string;
pollId: string;
optionId: string;
userId: string | null;
voterName: string;
voterEmail: string | null;
voterToken: string | null;
value: PollVoteValue;
createdAt: string;
updatedAt: string;
}
export interface SchedulingPollComment {
id: string;
pollId: string;
userId: string | null;
authorName: string;
content: string;
createdAt: string;
}
export interface SchedulingPoll {
id: string;
slug: string;
title: string;
description: string | null;
location: string | null;
status: SchedulingPollStatus;
timezone: string;
finalizedOptionId: string | null;
finalizedOption: SchedulingPollOption | null;
convertedShiftId: string | null;
convertedGancioEventId: number | null;
votingDeadline: string | null;
allowAnonymous: boolean;
notifyOnVote: boolean;
createdByUserId: string;
createdBy?: { id: string; name: string | null; email: string };
createdAt: string;
updatedAt: string;
options?: SchedulingPollOption[];
votes?: SchedulingPollVote[];
comments?: SchedulingPollComment[];
_count?: { options: number; votes: number; comments: number };
}
export interface PollsListResponse {
polls: SchedulingPoll[];
pagination: PaginationMeta;
}
export interface PollDetailResponse extends SchedulingPoll {
options: SchedulingPollOption[];
comments: SchedulingPollComment[];
voters: Array<{
name: string;
votes: Record<string, PollVoteValue>;
}>;
}

View File

@ -21,6 +21,7 @@ export interface SmsContactListEntry {
email: string | null;
customFields: Record<string, string> | null;
createdAt: string;
list?: { id: string; name: string };
}
// --- Campaigns ---
@ -106,6 +107,34 @@ export interface SmsConversation {
updatedAt: string;
}
// --- Contact Search ---
export interface SmsContactSearchResult {
phone: string;
name: string | null;
source: 'sms_contact' | 'crm_contact' | 'conversation';
sourceId: string;
contactId?: string;
}
// --- Templates ---
export interface SmsMessageTemplate {
id: string;
name: string;
template: string;
description: string | null;
category: string | null;
isFavorite: boolean;
usageCount: number;
createdByUserId: string | null;
createdByUser?: { id: string; name: string | null; email: string };
createdAt: string;
updatedAt: string;
variables?: string[];
isSystem?: boolean;
}
// --- Setup ---
export interface SmsSetupStatus {

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,127 @@
-- CreateEnum
CREATE TYPE "SchedulingPollStatus" AS ENUM ('OPEN', 'CLOSED', 'FINALIZED', 'CANCELLED');
-- CreateEnum
CREATE TYPE "PollVoteValue" AS ENUM ('YES', 'IF_NEED_BE', 'NO');
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_meeting_planner" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "scheduling_polls" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"location" TEXT,
"status" "SchedulingPollStatus" NOT NULL DEFAULT 'OPEN',
"timezone" TEXT NOT NULL DEFAULT 'America/Edmonton',
"finalized_option_id" TEXT,
"converted_shift_id" TEXT,
"converted_gancio_event_id" INTEGER,
"voting_deadline" TIMESTAMP(3),
"allow_anonymous" BOOLEAN NOT NULL DEFAULT true,
"notify_on_vote" BOOLEAN NOT NULL DEFAULT true,
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "scheduling_polls_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "scheduling_poll_options" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"date" DATE NOT NULL,
"start_time" TEXT NOT NULL,
"end_time" TEXT NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "scheduling_poll_options_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "scheduling_poll_votes" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"option_id" TEXT NOT NULL,
"user_id" TEXT,
"voter_name" TEXT NOT NULL,
"voter_email" TEXT,
"voter_token" TEXT,
"value" "PollVoteValue" NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "scheduling_poll_votes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "scheduling_poll_comments" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"user_id" TEXT,
"author_name" TEXT NOT NULL,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "scheduling_poll_comments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "scheduling_polls_slug_key" ON "scheduling_polls"("slug");
-- CreateIndex
CREATE UNIQUE INDEX "scheduling_polls_finalized_option_id_key" ON "scheduling_polls"("finalized_option_id");
-- CreateIndex
CREATE UNIQUE INDEX "scheduling_polls_converted_shift_id_key" ON "scheduling_polls"("converted_shift_id");
-- CreateIndex
CREATE INDEX "scheduling_polls_created_by_user_id_idx" ON "scheduling_polls"("created_by_user_id");
-- CreateIndex
CREATE INDEX "scheduling_polls_status_idx" ON "scheduling_polls"("status");
-- CreateIndex
CREATE INDEX "scheduling_poll_options_poll_id_idx" ON "scheduling_poll_options"("poll_id");
-- CreateIndex
CREATE INDEX "scheduling_poll_votes_poll_id_idx" ON "scheduling_poll_votes"("poll_id");
-- CreateIndex
CREATE UNIQUE INDEX "scheduling_poll_votes_option_id_user_id_key" ON "scheduling_poll_votes"("option_id", "user_id");
-- CreateIndex
CREATE UNIQUE INDEX "scheduling_poll_votes_option_id_voter_token_key" ON "scheduling_poll_votes"("option_id", "voter_token");
-- CreateIndex
CREATE INDEX "scheduling_poll_comments_poll_id_idx" ON "scheduling_poll_comments"("poll_id");
-- AddForeignKey
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_finalized_option_id_fkey" FOREIGN KEY ("finalized_option_id") REFERENCES "scheduling_poll_options"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_converted_shift_id_fkey" FOREIGN KEY ("converted_shift_id") REFERENCES "shifts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduling_polls" ADD CONSTRAINT "scheduling_polls_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduling_poll_options" ADD CONSTRAINT "scheduling_poll_options_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "scheduling_poll_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduling_poll_comments" ADD CONSTRAINT "scheduling_poll_comments_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "scheduling_poll_comments" ADD CONSTRAINT "scheduling_poll_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -155,6 +155,11 @@ model User {
// People CRM
contact Contact? @relation("UserContact")
// Scheduling polls
schedulingPollsCreated SchedulingPoll[] @relation("PollCreator")
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
@@map("users")
}
@ -686,6 +691,9 @@ model Shift {
canvassVisits CanvassVisit[]
canvassSessions CanvassSession[]
// Scheduling poll conversion
convertedFromPoll SchedulingPoll? @relation("PollConvertedShift")
@@index([cutId])
@@index([seriesId])
@@map("shifts")
@ -889,6 +897,7 @@ model SiteSettings {
enablePeople Boolean @default(false) @map("enable_people")
enableSocial Boolean @default(false) @map("enable_social")
enableMeet Boolean @default(false) @map("enable_meet")
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
// SMS connection config (overrides env vars when non-empty)
@ -4294,3 +4303,103 @@ model Meeting {
@@map("meetings")
}
// ============================================================================
// SCHEDULING POLLS (Meeting Planner)
// ============================================================================
enum SchedulingPollStatus {
OPEN
CLOSED
FINALIZED
CANCELLED
}
enum PollVoteValue {
YES
IF_NEED_BE
NO
}
model SchedulingPoll {
id String @id @default(cuid())
slug String @unique
title String
description String? @db.Text
location String?
status SchedulingPollStatus @default(OPEN)
timezone String @default("America/Edmonton")
finalizedOptionId String? @unique @map("finalized_option_id")
finalizedOption SchedulingPollOption? @relation("FinalizedOption", fields: [finalizedOptionId], references: [id], onDelete: SetNull)
convertedShiftId String? @unique @map("converted_shift_id")
convertedShift Shift? @relation("PollConvertedShift", fields: [convertedShiftId], references: [id], onDelete: SetNull)
convertedGancioEventId Int? @map("converted_gancio_event_id")
votingDeadline DateTime? @map("voting_deadline")
allowAnonymous Boolean @default(true) @map("allow_anonymous")
notifyOnVote Boolean @default(true) @map("notify_on_vote")
createdByUserId String @map("created_by_user_id")
createdBy User @relation("PollCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
options SchedulingPollOption[] @relation("PollOptions")
votes SchedulingPollVote[] @relation("PollVotes")
comments SchedulingPollComment[] @relation("PollComments")
@@index([createdByUserId])
@@index([status])
@@map("scheduling_polls")
}
model SchedulingPollOption {
id String @id @default(cuid())
pollId String @map("poll_id")
poll SchedulingPoll @relation("PollOptions", fields: [pollId], references: [id], onDelete: Cascade)
date DateTime @db.Date
startTime String @map("start_time") // HH:MM
endTime String @map("end_time") // HH:MM
sortOrder Int @default(0) @map("sort_order")
votes SchedulingPollVote[] @relation("OptionVotes")
// Reverse 1:1 for finalized option
finalizedForPoll SchedulingPoll? @relation("FinalizedOption")
@@index([pollId])
@@map("scheduling_poll_options")
}
model SchedulingPollVote {
id String @id @default(cuid())
pollId String @map("poll_id")
poll SchedulingPoll @relation("PollVotes", fields: [pollId], references: [id], onDelete: Cascade)
optionId String @map("option_id")
option SchedulingPollOption @relation("OptionVotes", fields: [optionId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
user User? @relation("PollVoter", fields: [userId], references: [id], onDelete: SetNull)
voterName String @map("voter_name")
voterEmail String? @map("voter_email")
voterToken String? @map("voter_token") // anonymous edit access (cuid)
value PollVoteValue
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([optionId, userId])
@@unique([optionId, voterToken])
@@index([pollId])
@@map("scheduling_poll_votes")
}
model SchedulingPollComment {
id String @id @default(cuid())
pollId String @map("poll_id")
poll SchedulingPoll @relation("PollComments", fields: [pollId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
user User? @relation("PollCommenter", fields: [userId], references: [id], onDelete: SetNull)
authorName String @map("author_name")
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([pollId])
@@map("scheduling_poll_comments")
}

View File

@ -445,6 +445,23 @@ async function main() {
showTitle: true,
},
},
{
id: 'default-scheduling-poll',
type: 'scheduling-poll',
label: 'Scheduling Poll',
category: 'Influence',
sortOrder: 17,
schema: {
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
showComments: { type: 'boolean', label: 'Show Comments', default: true },
title: { type: 'string', label: 'Section Title', default: 'Vote on a Meeting Time' },
},
defaults: {
pollSlug: '',
showComments: true,
title: 'Vote on a Meeting Time',
},
},
];
for (const block of defaultBlocks) {

View File

@ -153,6 +153,11 @@ const start = async () => {
await fastify.register(photosPublicRoutes, { prefix: '/api' });
await fastify.register(photoEngagementRoutes, { prefix: '/api' });
// 404 handler for unmatched routes
fastify.setNotFoundHandler((_request, reply) => {
reply.status(404).send({ error: { message: 'Route not found', code: 'NOT_FOUND' } });
});
const port = env.MEDIA_API_PORT;
const host = '0.0.0.0';

View File

@ -360,6 +360,23 @@ export const eventSubmissionRateLimit = rateLimit({
},
});
export const errorReportRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:error-report:',
}),
message: {
error: {
message: 'Too many error reports, please try again later',
code: 'ERROR_REPORT_RATE_LIMIT_EXCEEDED',
},
},
});
export const healthMetricsRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 30, // 30 requests per minute

View File

@ -9,7 +9,7 @@ import { logger } from '../../utils/logger';
export interface UnifiedCalendarItem {
id: string;
type: 'shift' | 'event';
type: 'shift' | 'event' | 'poll';
title: string;
date: string; // YYYY-MM-DD
startTime: string; // HH:MM
@ -23,6 +23,11 @@ export interface UnifiedCalendarItem {
// Event-specific
gancioEventId?: number;
gancioUrl?: string;
// Poll-specific
pollId?: string;
pollSlug?: string;
pollStatus?: string;
pollVoteCount?: number;
}
export interface UnifiedCalendarResponse {
@ -50,10 +55,11 @@ export const unifiedCalendarService = {
// Set end to end of day
end.setHours(23, 59, 59, 999);
// Fetch shifts and Gancio events in parallel
const [shifts, gancioEvents] = await Promise.all([
// Fetch shifts, Gancio events, and polls in parallel
const [shifts, gancioEvents, pollItems] = await Promise.all([
this.fetchShifts(start, end),
this.fetchGancioEvents(start, end),
this.fetchPolls(start, end),
]);
// Build set of Gancio event IDs that correspond to synced shifts (to deduplicate)
@ -99,7 +105,7 @@ export const unifiedCalendarService = {
});
// Merge and group by date
const allItems = [...shiftItems, ...eventItems];
const allItems = [...shiftItems, ...eventItems, ...pollItems];
allItems.sort((a, b) => a.startTime.localeCompare(b.startTime));
const dates: Record<string, { count: number; items: UnifiedCalendarItem[] }> = {};
@ -163,6 +169,53 @@ export const unifiedCalendarService = {
});
},
async fetchPolls(start: Date, end: Date): Promise<UnifiedCalendarItem[]> {
try {
const polls = await prisma.schedulingPoll.findMany({
where: {
status: { in: ['OPEN', 'FINALIZED'] },
options: {
some: { date: { gte: start, lte: end } },
},
},
include: {
options: { orderBy: { sortOrder: 'asc' } },
_count: { select: { votes: true } },
},
});
const items: UnifiedCalendarItem[] = [];
for (const poll of polls) {
// For finalized polls, only show the selected option
const optionsToShow = poll.finalizedOptionId
? poll.options.filter(o => o.id === poll.finalizedOptionId)
: poll.options;
for (const opt of optionsToShow) {
const optDate = opt.date.toISOString().split('T')[0];
items.push({
id: `poll-${poll.id}-${opt.id}`,
type: 'poll',
title: poll.title,
date: optDate,
startTime: opt.startTime,
endTime: opt.endTime,
location: poll.location,
tags: ['scheduling', 'poll'],
pollId: poll.id,
pollSlug: poll.slug,
pollStatus: poll.status,
pollVoteCount: poll._count.votes,
});
}
}
return items;
} catch (err) {
logger.debug('Failed to fetch polls for calendar:', err);
return [];
}
},
async fetchGancioEvents(start: Date, end: Date): Promise<GancioEvent[]> {
try {
const events = await gancioClient.fetchPublicEvents();

View File

@ -0,0 +1,37 @@
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
export const pollVoteRateLimit = 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:poll-vote:',
}),
message: {
error: {
message: 'Too many vote submissions, please try again later',
code: 'POLL_VOTE_RATE_LIMIT_EXCEEDED',
},
},
});
export const pollCommentRateLimit = 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:poll-comment:',
}),
message: {
error: {
message: 'Too many comments, please try again later',
code: 'POLL_COMMENT_RATE_LIMIT_EXCEEDED',
},
},
});

View File

@ -0,0 +1,192 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { meetingPlannerService } from './meeting-planner.service';
import {
createPollSchema,
updatePollSchema,
addOptionsSchema,
submitVotesSchema,
submitCommentSchema,
finalizePollSchema,
convertToShiftSchema,
listPollsSchema,
} from './meeting-planner.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { pollVoteRateLimit, pollCommentRateLimit } from './meeting-planner.rate-limits';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...ADMIN_ROLES));
// List polls
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await meetingPlannerService.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 meetingPlannerService.findById(id);
res.json(poll);
} catch (err) { next(err); }
});
// Create poll
adminRouter.post('/', validate(createPollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await meetingPlannerService.create(req.body, req.user!.id);
res.status(201).json(poll);
} catch (err) { next(err); }
});
// Update poll
adminRouter.put('/:id', validate(updatePollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await meetingPlannerService.update(id, req.body);
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;
await meetingPlannerService.delete(id);
res.json({ message: 'Poll deleted' });
} catch (err) { next(err); }
});
// Add options
adminRouter.post('/:id/options', validate(addOptionsSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await meetingPlannerService.addOptions(id, req.body);
res.json(poll);
} catch (err) { next(err); }
});
// Remove option
adminRouter.delete('/:id/options/:optionId', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const optionId = req.params.optionId as string;
const poll = await meetingPlannerService.removeOption(id, optionId);
res.json(poll);
} catch (err) { next(err); }
});
// Finalize poll
adminRouter.post('/:id/finalize', validate(finalizePollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await meetingPlannerService.finalize(id, req.body);
res.json(poll);
} catch (err) { next(err); }
});
// Convert to shift
adminRouter.post('/:id/convert-to-shift', validate(convertToShiftSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const shift = await meetingPlannerService.convertToShift(id, req.body);
res.json(shift);
} catch (err) { next(err); }
});
// Convert to Gancio event
adminRouter.post('/:id/convert-to-event', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const result = await meetingPlannerService.convertToEvent(id);
res.json(result);
} catch (err) { next(err); }
});
// Delete comment
adminRouter.delete('/:id/comments/:commentId', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const commentId = req.params.commentId as string;
await meetingPlannerService.deleteComment(id, commentId);
res.json({ message: 'Comment deleted' });
} catch (err) { next(err); }
});
// --- Public Router ---
const publicRouter = Router();
// Public listing of open polls
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await meetingPlannerService.findAll({
status: 'OPEN',
limit: 50,
page: 1,
});
res.json(result);
} catch (err) { next(err); }
});
// View poll by slug
publicRouter.get('/public/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const poll = await meetingPlannerService.findBySlug(slug);
res.json(poll);
} catch (err) { next(err); }
});
// Submit votes
publicRouter.post('/public/:slug/vote', pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
// Try to get userId from optional auth header
let userId: string | undefined;
try {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const jwt = await import('jsonwebtoken');
const { env } = await import('../../config/env');
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
userId = decoded.id;
}
} catch { /* not authenticated, that's fine */ }
const result = await meetingPlannerService.submitVotes(slug, req.body, userId);
res.json(result);
} catch (err) { next(err); }
});
// Add comment
publicRouter.post('/public/:slug/comment', pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
let userId: string | undefined;
try {
const authHeader = req.headers.authorization;
if (authHeader?.startsWith('Bearer ')) {
const jwt = await import('jsonwebtoken');
const { env } = await import('../../config/env');
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
userId = decoded.id;
}
} catch { /* not authenticated */ }
const comment = await meetingPlannerService.addComment(slug, req.body, userId);
res.status(201).json(comment);
} catch (err) { next(err); }
});
export { adminRouter as meetingPlannerAdminRouter, publicRouter as meetingPlannerPublicRouter };

View File

@ -0,0 +1,77 @@
import { z } from 'zod';
import { SchedulingPollStatus, PollVoteValue } from '@prisma/client';
export const createPollSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional(),
location: z.string().max(500).optional(),
timezone: z.string().default('America/Edmonton'),
allowAnonymous: z.boolean().optional().default(true),
notifyOnVote: z.boolean().optional().default(true),
votingDeadline: z.string().datetime().optional(),
options: z.array(z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
})).min(2, 'At least 2 options required').max(20, 'Maximum 20 options'),
});
export const updatePollSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
location: z.string().max(500).nullable().optional(),
timezone: z.string().optional(),
allowAnonymous: z.boolean().optional(),
notifyOnVote: z.boolean().optional(),
votingDeadline: z.string().datetime().nullable().optional(),
status: z.nativeEnum(SchedulingPollStatus).optional(),
});
export const addOptionsSchema = z.object({
options: z.array(z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
endTime: z.string().regex(/^\d{2}:\d{2}$/, 'End time must be HH:MM'),
})).min(1).max(20),
});
export const submitVotesSchema = z.object({
voterName: z.string().min(1, 'Name is required').max(100),
voterEmail: z.string().email().max(200).optional(),
voterToken: z.string().optional(),
votes: z.array(z.object({
optionId: z.string().min(1),
value: z.nativeEnum(PollVoteValue),
})).min(1, 'At least one vote required'),
});
export const submitCommentSchema = z.object({
authorName: z.string().min(1, 'Name is required').max(100),
content: z.string().min(1, 'Comment is required').max(2000),
});
export const finalizePollSchema = z.object({
optionId: z.string().min(1, 'Option ID is required'),
});
export const convertToShiftSchema = z.object({
maxVolunteers: z.number().int().min(1).default(10),
isPublic: z.boolean().optional().default(true),
cutId: z.string().optional(),
});
export const listPollsSchema = 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(SchedulingPollStatus).optional(),
});
export type CreatePollInput = z.infer<typeof createPollSchema>;
export type UpdatePollInput = z.infer<typeof updatePollSchema>;
export type AddOptionsInput = z.infer<typeof addOptionsSchema>;
export type SubmitVotesInput = z.infer<typeof submitVotesSchema>;
export type SubmitCommentInput = z.infer<typeof submitCommentSchema>;
export type FinalizePollInput = z.infer<typeof finalizePollSchema>;
export type ConvertToShiftInput = z.infer<typeof convertToShiftSchema>;
export type ListPollsInput = z.infer<typeof listPollsSchema>;

View File

@ -0,0 +1,491 @@
import { Prisma, PollVoteValue } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import { emailService } from '../../services/email.service';
import { generateSlug } from '../../utils/slug';
import { logger } from '../../utils/logger';
import type {
CreatePollInput,
UpdatePollInput,
AddOptionsInput,
SubmitVotesInput,
SubmitCommentInput,
FinalizePollInput,
ConvertToShiftInput,
ListPollsInput,
} from './meeting-planner.schemas';
const pollInclude = {
options: { orderBy: { sortOrder: 'asc' as const } },
createdBy: { select: { id: true, name: true, email: true } },
_count: { select: { options: true, votes: true, comments: true } },
} as const;
const pollDetailInclude = {
options: {
orderBy: { sortOrder: 'asc' as const },
include: {
votes: { orderBy: { createdAt: 'asc' as const } },
},
},
comments: { orderBy: { createdAt: 'asc' as const } },
createdBy: { select: { id: true, name: true, email: true } },
_count: { select: { options: true, votes: true, comments: true } },
} as const;
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
return options.map((opt) => {
let yesCount = 0;
let ifNeedBeCount = 0;
let noCount = 0;
for (const v of opt.votes) {
if (v.value === 'YES') yesCount++;
else if (v.value === 'IF_NEED_BE') ifNeedBeCount++;
else noCount++;
}
return {
...opt,
yesCount,
ifNeedBeCount,
noCount,
score: yesCount * 2 + ifNeedBeCount,
};
});
}
function groupVotesByVoter(votes: Array<{
voterName: string;
voterToken: string | null;
userId: string | null;
optionId: string;
value: PollVoteValue;
}>) {
const voterMap = new Map<string, { name: string; votes: Record<string, PollVoteValue> }>();
for (const vote of votes) {
const key = vote.userId || vote.voterToken || vote.voterName;
if (!voterMap.has(key)) {
voterMap.set(key, { name: vote.voterName, votes: {} });
}
voterMap.get(key)!.votes[vote.optionId] = vote.value;
}
return Array.from(voterMap.values());
}
export const meetingPlannerService = {
async findAll(filters: ListPollsInput) {
const { page, limit, search, status } = filters;
const where: Prisma.SchedulingPollWhereInput = {};
if (status) where.status = status;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const [polls, total] = await Promise.all([
prisma.schedulingPoll.findMany({
where,
include: pollInclude,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.schedulingPoll.count({ where }),
]);
return {
polls,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
},
async findById(id: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id },
include: pollDetailInclude,
});
if (!poll) throw new AppError(404, 'Poll not found');
const optionsWithCounts = aggregateVotes(poll.options);
const allVotes = poll.options.flatMap((opt) =>
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
);
const voters = groupVotesByVoter(allVotes);
return { ...poll, options: optionsWithCounts, voters };
},
async findBySlug(slug: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { slug },
include: pollDetailInclude,
});
if (!poll) throw new AppError(404, 'Poll not found');
const optionsWithCounts = aggregateVotes(poll.options);
const allVotes = poll.options.flatMap((opt) =>
opt.votes.map((v) => ({ ...v, optionId: opt.id }))
);
const voters = groupVotesByVoter(allVotes);
return { ...poll, options: optionsWithCounts, voters };
},
async create(data: CreatePollInput, userId: string) {
const slug = generateSlug(data.title);
const poll = await prisma.schedulingPoll.create({
data: {
slug,
title: data.title,
description: data.description,
location: data.location,
timezone: data.timezone,
allowAnonymous: data.allowAnonymous,
notifyOnVote: data.notifyOnVote,
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
createdByUserId: userId,
options: {
create: data.options.map((opt, i) => ({
date: new Date(opt.date),
startTime: opt.startTime,
endTime: opt.endTime,
sortOrder: i,
})),
},
},
include: pollInclude,
});
return poll;
},
async update(id: string, data: UpdatePollInput) {
const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Poll not found');
const updateData: Prisma.SchedulingPollUncheckedUpdateInput = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.location !== undefined) updateData.location = data.location;
if (data.timezone !== undefined) updateData.timezone = data.timezone;
if (data.allowAnonymous !== undefined) updateData.allowAnonymous = data.allowAnonymous;
if (data.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
if (data.votingDeadline !== undefined) {
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
}
if (data.status !== undefined) updateData.status = data.status;
return prisma.schedulingPoll.update({
where: { id },
data: updateData,
include: pollInclude,
});
},
async delete(id: string) {
const existing = await prisma.schedulingPoll.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Poll not found');
await prisma.schedulingPoll.delete({ where: { id } });
},
async addOptions(pollId: string, data: AddOptionsInput) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id: pollId },
include: { options: true },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'OPEN') throw new AppError(400, 'Cannot add options to a non-open poll');
const maxSort = poll.options.reduce((max, o) => Math.max(max, o.sortOrder), -1);
await prisma.schedulingPollOption.createMany({
data: data.options.map((opt, i) => ({
pollId,
date: new Date(opt.date),
startTime: opt.startTime,
endTime: opt.endTime,
sortOrder: maxSort + 1 + i,
})),
});
return this.findById(pollId);
},
async removeOption(pollId: string, optionId: string) {
const option = await prisma.schedulingPollOption.findFirst({
where: { id: optionId, pollId },
});
if (!option) throw new AppError(404, 'Option not found');
await prisma.schedulingPollOption.delete({ where: { id: optionId } });
return this.findById(pollId);
},
async submitVotes(slug: string, data: SubmitVotesInput, userId?: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { slug },
include: { options: true, createdBy: { select: { email: true, name: true } } },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'OPEN') throw new AppError(400, 'This poll is no longer accepting votes');
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
throw new AppError(400, 'The voting deadline has passed');
}
if (!poll.allowAnonymous && !userId) {
throw new AppError(401, 'This poll requires authentication to vote');
}
// Validate all optionIds belong to this poll
const optionIds = new Set(poll.options.map((o) => o.id));
for (const vote of data.votes) {
if (!optionIds.has(vote.optionId)) {
throw new AppError(400, `Invalid option ID: ${vote.optionId}`);
}
}
// Generate token for anonymous voters (or reuse existing)
const voterToken = userId ? null : (data.voterToken || generateVoterToken());
// Upsert votes in a transaction
await prisma.$transaction(
data.votes.map((vote) => {
if (userId) {
return prisma.schedulingPollVote.upsert({
where: { optionId_userId: { optionId: vote.optionId, userId } },
create: {
pollId: poll.id,
optionId: vote.optionId,
userId,
voterName: data.voterName,
voterEmail: data.voterEmail,
value: vote.value,
},
update: {
voterName: data.voterName,
voterEmail: data.voterEmail,
value: vote.value,
},
});
} else {
return prisma.schedulingPollVote.upsert({
where: { optionId_voterToken: { optionId: vote.optionId, voterToken: voterToken! } },
create: {
pollId: poll.id,
optionId: vote.optionId,
voterName: data.voterName,
voterEmail: data.voterEmail,
voterToken,
value: vote.value,
},
update: {
voterName: data.voterName,
voterEmail: data.voterEmail,
value: vote.value,
},
});
}
})
);
// Notify organizer
if (poll.notifyOnVote) {
this.notifyOrganizer(poll.createdBy.email, poll.title, data.voterName).catch((err) =>
logger.error('Failed to send vote notification', { error: err })
);
}
return { voterToken };
},
async addComment(slug: string, data: SubmitCommentInput, userId?: string) {
const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
if (!poll) throw new AppError(404, 'Poll not found');
return prisma.schedulingPollComment.create({
data: {
pollId: poll.id,
userId,
authorName: data.authorName,
content: data.content,
},
});
},
async deleteComment(pollId: string, commentId: string) {
const comment = await prisma.schedulingPollComment.findFirst({
where: { id: commentId, pollId },
});
if (!comment) throw new AppError(404, 'Comment not found');
await prisma.schedulingPollComment.delete({ where: { id: commentId } });
},
async finalize(id: string, data: FinalizePollInput) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id },
include: { options: true },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status === 'FINALIZED') throw new AppError(400, 'Poll is already finalized');
const option = poll.options.find((o) => o.id === data.optionId);
if (!option) throw new AppError(400, 'Option not found in this poll');
const updated = await prisma.schedulingPoll.update({
where: { id },
data: {
status: 'FINALIZED',
finalizedOptionId: data.optionId,
},
include: pollDetailInclude,
});
// Notify all voters with emails
this.notifyVotersFinalized(updated).catch((err) =>
logger.error('Failed to send finalization notifications', { error: err })
);
return updated;
},
async convertToShift(id: string, data: ConvertToShiftInput) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id },
include: { options: true },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
if (poll.convertedShiftId) throw new AppError(400, 'Poll has already been converted to a shift');
if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');
const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
if (!option) throw new AppError(400, 'Finalized option not found');
const [shift] = await prisma.$transaction([
prisma.shift.create({
data: {
title: poll.title,
description: poll.description,
date: option.date,
startTime: option.startTime,
endTime: option.endTime,
location: poll.location,
maxVolunteers: data.maxVolunteers,
isPublic: data.isPublic,
cutId: data.cutId,
},
}),
]);
await prisma.schedulingPoll.update({
where: { id },
data: { convertedShiftId: shift.id },
});
return shift;
},
async convertToEvent(id: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id },
include: { options: true },
});
if (!poll) throw new AppError(404, 'Poll not found');
if (poll.status !== 'FINALIZED') throw new AppError(400, 'Poll must be finalized before converting');
if (poll.convertedGancioEventId) throw new AppError(400, 'Poll has already been converted to an event');
if (!poll.finalizedOptionId) throw new AppError(400, 'No finalized option selected');
const option = poll.options.find((o) => o.id === poll.finalizedOptionId);
if (!option) throw new AppError(400, 'Finalized option not found');
// Dynamically import gancio client to avoid hard dependency
const { gancioClient } = await import('../../services/gancio.client');
const eventId = await gancioClient.createEvent({
title: poll.title,
description: poll.description,
location: poll.location,
date: option.date,
startTime: option.startTime,
endTime: option.endTime,
});
if (!eventId) throw new AppError(500, 'Failed to create Gancio event');
await prisma.schedulingPoll.update({
where: { id },
data: { convertedGancioEventId: eventId },
});
return { gancioEventId: eventId };
},
async notifyOrganizer(email: string, pollTitle: string, voterName: string) {
try {
await emailService.sendEmail({
to: email,
subject: `New vote on "${pollTitle}"`,
html: `<p><strong>${escapeHtml(voterName)}</strong> voted on your scheduling poll "<strong>${escapeHtml(pollTitle)}</strong>".</p>`,
text: `${voterName} voted on your scheduling poll "${pollTitle}".`,
});
} catch (err) {
logger.error('Failed to send vote notification email', { error: err });
}
},
async notifyVotersFinalized(poll: any) {
const finalOption = poll.options.find((o: any) => o.id === poll.finalizedOptionId);
if (!finalOption) return;
const dateStr = new Date(finalOption.date).toLocaleDateString('en-CA', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
const timeStr = `${finalOption.startTime} - ${finalOption.endTime}`;
// Collect unique voter emails
const voterEmails = new Set<string>();
for (const opt of poll.options) {
for (const vote of opt.votes) {
if (vote.voterEmail) voterEmails.add(vote.voterEmail);
}
}
for (const email of voterEmails) {
try {
await emailService.sendEmail({
to: email,
subject: `Date confirmed for "${poll.title}"`,
html: `<p>The date for "<strong>${escapeHtml(poll.title)}</strong>" has been confirmed:</p>
<p><strong>${dateStr}</strong><br/>${timeStr}</p>
${poll.location ? `<p>Location: ${escapeHtml(poll.location)}</p>` : ''}`,
text: `The date for "${poll.title}" has been confirmed:\n${dateStr}\n${timeStr}${poll.location ? `\nLocation: ${poll.location}` : ''}`,
});
} catch (err) {
logger.error('Failed to send finalization email', { error: err, email });
}
}
},
};
function generateVoterToken(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let token = '';
for (let i = 0; i < 24; i++) {
token += chars[Math.floor(Math.random() * chars.length)];
}
return token;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@ -1618,30 +1618,178 @@ export const peopleService = {
currentDepth++;
}
// Include registered Users that don't have Contact records yet
// (so they appear in the graph alongside managed contacts)
if (!center && (!source || source === 'USER') && nodesMap.size < MAX_NODES) {
// -----------------------------------------------------------------------
// Include all source types (not just Contacts) so the graph reflects
// the same universe of people shown in the table/cards views.
// We dedup by normalized email/phone to avoid double-counting people
// who already appear via a Contact or User node above.
// -----------------------------------------------------------------------
// Build a set of emails/phones already represented in the graph
const representedEmails = new Set<string>();
const representedPhones = new Set<string>();
for (const n of nodesMap.values()) {
const ne = normalizeEmail(n.email);
if (ne) representedEmails.add(ne);
}
// Contacts have phones too — check rootContacts and connection neighbors
for (const c of rootContacts) {
const np = normalizePhone(c.phone);
if (np) representedPhones.add(np);
}
function isAlreadyRepresented(email: string | null, phone: string | null): boolean {
const ne = normalizeEmail(email);
if (ne && representedEmails.has(ne)) return true;
const np = normalizePhone(phone);
if (np && representedPhones.has(np)) return true;
return false;
}
function markRepresented(email: string | null, phone: string | null) {
const ne = normalizeEmail(email);
if (ne) representedEmails.add(ne);
const np = normalizePhone(phone);
if (np) representedPhones.add(np);
}
// Mark all existing nodes
for (const n of nodesMap.values()) {
markRepresented(n.email, null);
}
if (!center) {
// Users (not yet represented via a Contact node)
if ((!source || source === 'USER') && nodesMap.size < MAX_NODES) {
const users = await prisma.user.findMany({
select: { id: true, name: true, email: true },
select: { id: true, name: true, email: true, phone: true },
take: MAX_NODES - nodesMap.size,
orderBy: { createdAt: 'desc' },
});
for (const user of users) {
if (contactUserIds.has(user.id)) continue; // Already represented as a Contact node
if (contactUserIds.has(user.id)) continue;
if (isAlreadyRepresented(user.email, user.phone)) continue;
if (nodesMap.size >= MAX_NODES) break;
const nodeId = `user:${user.id}`;
nodesMap.set(nodeId, {
id: nodeId,
contactId: null,
id: nodeId, contactId: null,
displayName: user.name || user.email,
email: user.email,
source: 'USER',
supportLevel: null,
tags: [],
engagementScore: null,
email: user.email, source: 'USER',
supportLevel: null, tags: [], engagementScore: null,
});
markRepresented(user.email, user.phone);
}
}
// Address occupants
if ((!source || source === 'ADDRESS_OCCUPANT') && nodesMap.size < MAX_NODES) {
const addresses = await prisma.address.findMany({
select: { id: true, firstName: true, lastName: true, email: true, phone: true, supportLevel: true },
where: { OR: [{ firstName: { not: null } }, { lastName: { not: null } }] },
take: MAX_NODES - nodesMap.size,
});
for (const a of addresses) {
if (isAlreadyRepresented(a.email, a.phone)) continue;
if (nodesMap.size >= MAX_NODES) break;
const nodeId = `addr:${a.id}`;
nodesMap.set(nodeId, {
id: nodeId, contactId: null,
displayName: buildDisplayName(a.firstName, a.lastName, a.email, `Address ${a.id.slice(0, 6)}`),
email: a.email, source: 'ADDRESS_OCCUPANT',
supportLevel: a.supportLevel, tags: [], engagementScore: null,
});
markRepresented(a.email, a.phone);
}
}
// Campaign email senders
if ((!source || source === 'CAMPAIGN_SENDER') && nodesMap.size < MAX_NODES) {
const campaignEmails = await prisma.campaignEmail.findMany({
select: { userEmail: true, userName: true },
where: { userEmail: { not: null } },
distinct: ['userEmail'],
take: MAX_NODES - nodesMap.size,
orderBy: { sentAt: 'desc' },
});
for (const ce of campaignEmails) {
if (!ce.userEmail) continue;
if (isAlreadyRepresented(ce.userEmail, null)) continue;
if (nodesMap.size >= MAX_NODES) break;
const nodeId = `cemail:${ce.userEmail}`;
nodesMap.set(nodeId, {
id: nodeId, contactId: null,
displayName: ce.userName || ce.userEmail,
email: ce.userEmail, source: 'CAMPAIGN_SENDER',
supportLevel: null, tags: [], engagementScore: null,
});
markRepresented(ce.userEmail, null);
}
}
// Shift signups
if ((!source || source === 'SHIFT_SIGNUP') && nodesMap.size < MAX_NODES) {
const shiftSignups = await prisma.shiftSignup.findMany({
select: { userEmail: true, userName: true, userPhone: true },
distinct: ['userEmail'],
take: MAX_NODES - nodesMap.size,
orderBy: { signupDate: 'desc' },
});
for (const ss of shiftSignups) {
if (isAlreadyRepresented(ss.userEmail, ss.userPhone)) continue;
if (nodesMap.size >= MAX_NODES) break;
const nodeId = `signup:${ss.userEmail}`;
nodesMap.set(nodeId, {
id: nodeId, contactId: null,
displayName: ss.userName || ss.userEmail,
email: ss.userEmail, source: 'SHIFT_SIGNUP',
supportLevel: null, tags: [], engagementScore: null,
});
markRepresented(ss.userEmail, ss.userPhone);
}
}
// SMS contacts
if ((!source || source === 'SMS_CONTACT') && nodesMap.size < MAX_NODES) {
const smsEntries = await prisma.smsContactListEntry.findMany({
select: { phone: true, name: true, email: true },
distinct: ['phone'],
take: MAX_NODES - nodesMap.size,
orderBy: { createdAt: 'desc' },
});
for (const sc of smsEntries) {
if (isAlreadyRepresented(sc.email, sc.phone)) continue;
if (nodesMap.size >= MAX_NODES) break;
const nodeId = `sms:${sc.phone}`;
nodesMap.set(nodeId, {
id: nodeId, contactId: null,
displayName: sc.name || sc.phone,
email: sc.email, source: 'SMS_CONTACT',
supportLevel: null, tags: [], engagementScore: null,
});
markRepresented(sc.email, sc.phone);
}
}
// Donations (Orders)
if ((!source || source === 'DONATION') && nodesMap.size < MAX_NODES) {
const orders = await prisma.order.findMany({
select: { buyerEmail: true, buyerName: true },
distinct: ['buyerEmail'],
take: MAX_NODES - nodesMap.size,
orderBy: { createdAt: 'desc' },
});
for (const o of orders) {
if (isAlreadyRepresented(o.buyerEmail, null)) continue;
if (nodesMap.size >= MAX_NODES) break;
const nodeId = `order:${o.buyerEmail}`;
nodesMap.set(nodeId, {
id: nodeId, contactId: null,
displayName: o.buyerName || o.buyerEmail || 'Unknown',
email: o.buyerEmail, source: 'DONATION',
supportLevel: null, tags: [], engagementScore: null,
});
markRepresented(o.buyerEmail, null);
}
}
}

View File

@ -0,0 +1,83 @@
import { Router } from 'express';
import { z } from 'zod';
import { UserRole } from '@prisma/client';
import { errorReportRateLimit } from '../../middleware/rate-limit';
import { getAdminEmailsByRole } from '../../services/notification.helper';
import { emailService } from '../../services/email.service';
import { logger } from '../../utils/logger';
const errorReportSchema = z.object({
url: z.string().min(1).max(2000),
message: z.string().max(500).optional(),
userAgent: z.string().max(500).optional(),
});
export const errorReportRouter = Router();
errorReportRouter.post('/', errorReportRateLimit, async (req, res) => {
try {
const parsed = errorReportSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: { message: 'Invalid input', code: 'VALIDATION_ERROR' } });
return;
}
const { url, message: userMessage, userAgent } = parsed.data;
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN]);
if (adminEmails.length === 0) {
logger.warn('No super admin emails found for 404 error report');
res.json({ success: true });
return;
}
const timestamp = new Date().toISOString();
const ip = req.ip || req.socket.remoteAddress || 'unknown';
const html = `
<h2>404 Error Report</h2>
<table style="border-collapse:collapse; font-family:sans-serif;">
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">URL:</td><td>${escapeHtml(url)}</td></tr>
${userMessage ? `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Message:</td><td>${escapeHtml(userMessage)}</td></tr>` : ''}
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">IP:</td><td>${escapeHtml(ip)}</td></tr>
${userAgent ? `<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">User Agent:</td><td>${escapeHtml(userAgent)}</td></tr>` : ''}
<tr><td style="padding:4px 12px 4px 0; font-weight:bold;">Timestamp:</td><td>${timestamp}</td></tr>
</table>
`;
const text = [
'404 Error Report',
`URL: ${url}`,
userMessage ? `Message: ${userMessage}` : '',
`IP: ${ip}`,
userAgent ? `User Agent: ${userAgent}` : '',
`Timestamp: ${timestamp}`,
].filter(Boolean).join('\n');
// Send to each admin (fire-and-forget per recipient)
for (const email of adminEmails) {
emailService.sendEmail({
to: email,
subject: `[404 Report] Page not found: ${url.slice(0, 100)}`,
html,
text,
}).catch((err) => {
logger.error('Failed to send 404 report email', { to: email, error: err instanceof Error ? err.message : String(err) });
});
}
res.json({ success: true });
} catch (err) {
logger.error('Error report submission failed', { error: err instanceof Error ? err.message : String(err) });
res.status(500).json({ error: { message: 'Internal server error', code: 'INTERNAL_ERROR' } });
}
});
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}

View File

@ -56,6 +56,7 @@ export const updateSiteSettingsSchema = z.object({
enablePeople: z.boolean().optional(),
enableSocial: z.boolean().optional(),
enableMeet: z.boolean().optional(),
enableMeetingPlanner: z.boolean().optional(),
autoSyncPeopleToMap: z.boolean().optional(),
// SMS connection config

View File

@ -3,7 +3,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsContactsService } from './sms-contacts.service';
import { createContactListSchema, updateContactListSchema, createContactEntrySchema } from './sms-contacts.schemas';
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
const router = Router();
@ -30,6 +30,18 @@ router.post('/', validate(createContactListSchema), async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/sms/contacts/all-entries — list entries across all lists
router.get('/all-entries', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
const listId = req.query.listId as string | undefined;
const search = req.query.search as string | undefined;
const result = await smsContactsService.getAllEntries(page, limit, listId, search);
res.json(result);
} catch (err) { next(err); }
});
// --- Database Import Previews (must be BEFORE /:id routes) ---
// GET /api/sms/contacts/preview-users — preview users with phone numbers
@ -132,6 +144,14 @@ router.post('/:id/entries', validate(createContactEntrySchema), async (req, res,
} catch (err) { next(err); }
});
// POST /api/sms/contacts/:id/entries/bulk — add multiple entries at once
router.post('/:id/entries/bulk', validate(bulkAddEntriesSchema), async (req, res, next) => {
try {
const result = await smsContactsService.addEntriesBulk(req.params.id as string, req.body.entries);
res.json(result);
} catch (err) { next(err); }
});
// DELETE /api/sms/contacts/:id/entries/:entryId — remove an entry
router.delete('/:id/entries/:entryId', async (req, res, next) => {
try {

View File

@ -15,6 +15,15 @@ export const createContactEntrySchema = z.object({
customFields: z.record(z.string()).optional(),
});
export const bulkAddEntriesSchema = z.object({
entries: z.array(z.object({
phone: z.string().min(7).max(20),
name: z.string().max(200).optional(),
email: z.string().email().max(200).optional(),
})).min(1).max(1000),
});
export type CreateContactListInput = z.infer<typeof createContactListSchema>;
export type UpdateContactListInput = z.infer<typeof updateContactListSchema>;
export type CreateContactEntryInput = z.infer<typeof createContactEntrySchema>;
export type BulkAddEntriesInput = z.infer<typeof bulkAddEntriesSchema>;

View File

@ -116,6 +116,32 @@ export const smsContactsService = {
});
},
async getAllEntries(page = 1, limit = 50, listId?: string, search?: string) {
const skip = (page - 1) * limit;
const where: Prisma.SmsContactListEntryWhereInput = {
list: { status: 'ACTIVE' },
};
if (listId) where.listId = listId;
if (search) {
where.OR = [
{ phone: { contains: search } },
{ name: { contains: search, mode: 'insensitive' } },
{ email: { contains: search, mode: 'insensitive' } },
];
}
const [items, total] = await Promise.all([
prisma.smsContactListEntry.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
include: { list: { select: { id: true, name: true } } },
}),
prisma.smsContactListEntry.count({ where }),
]);
return { items, total, page, limit };
},
async getEntries(listId: string, page = 1, limit = 100) {
const skip = (page - 1) * limit;
const [items, total] = await Promise.all([
@ -171,6 +197,32 @@ export const smsContactsService = {
return entry;
},
async addEntriesBulk(listId: string, entries: { phone: string; name?: string; email?: string }[]) {
let imported = 0;
let skipped = 0;
for (const entry of entries) {
const phone = normalizePhone(entry.phone);
if (!phone) { skipped++; continue; }
try {
await prisma.smsContactListEntry.upsert({
where: { listId_phone: { listId, phone } },
create: { listId, phone, name: entry.name, email: entry.email },
update: { name: entry.name, email: entry.email },
});
imported++;
} catch {
skipped++;
}
}
const total = await prisma.smsContactListEntry.count({ where: { listId } });
await prisma.smsContactList.update({ where: { id: listId }, data: { totalContacts: total } });
return { imported, skipped, total };
},
async deleteEntry(id: string) {
const entry = await prisma.smsContactListEntry.delete({ where: { id } });
// Update total count

View File

@ -24,6 +24,60 @@ router.get('/', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/sms/conversations/contact-search — search contacts for new conversation
router.get('/contact-search', async (req, res, next) => {
try {
const q = (req.query.q as string || '').trim();
if (q.length < 2) {
res.json({ results: [] });
return;
}
const results = await smsConversationsService.searchContacts(q);
res.json({ results });
} catch (err) { next(err); }
});
// POST /api/sms/conversations — start new conversation
router.post('/', async (req, res, next) => {
try {
const { phone, message, contactName, contactId } = req.body as {
phone?: string;
message?: string;
contactName?: string;
contactId?: string;
};
if (!phone || typeof phone !== 'string' || phone.replace(/\D/g, '').length < 7) {
res.status(400).json({ error: 'Valid phone number is required (min 7 digits)' });
return;
}
if (!message || typeof message !== 'string' || message.trim().length === 0) {
res.status(400).json({ error: 'Message is required' });
return;
}
if (message.length > 1600) {
res.status(400).json({ error: 'Message cannot exceed 1600 characters' });
return;
}
const conversation = await smsConversationsService.startConversation({
phone,
message: message.trim(),
contactName,
contactId,
});
res.status(201).json(conversation);
} catch (err: any) {
if (err.statusCode === 409) {
res.status(409).json({ error: err.message });
return;
}
if (err.statusCode === 400) {
res.status(400).json({ error: err.message });
return;
}
next(err);
}
});
// GET /api/sms/conversations/stats — conversation stats
router.get('/stats', async (_req, res, next) => {
try {

View File

@ -2,6 +2,24 @@ import { prisma } from '../../../config/database';
import { Prisma } from '@prisma/client';
import { smsQueueService } from '../../../services/sms-queue.service';
/**
* Normalize a phone number: strip non-digit characters, validate 10-11 digits.
*/
function normalizePhone(raw: string): string | null {
const digits = raw.replace(/\D/g, '');
if (digits.length === 10) return digits;
if (digits.length === 11 && digits.startsWith('1')) return digits;
return null;
}
export interface ContactSearchResult {
phone: string;
name: string | null;
source: 'sms_contact' | 'crm_contact' | 'conversation';
sourceId: string;
contactId?: string;
}
export const smsConversationsService = {
async findAll(options: {
page?: number;
@ -171,4 +189,181 @@ export const smsConversationsService = {
return { updated: result.count };
},
/**
* Search contacts across SMS lists, CRM contacts, and existing conversations.
* Deduplicates by phone number, prioritizing SMS contacts > CRM > conversations.
*/
async searchContacts(query: string, limit = 20): Promise<ContactSearchResult[]> {
const seen = new Map<string, ContactSearchResult>();
const [smsEntries, crmContacts, existingConvs] = await Promise.all([
// 1. SMS Contact List Entries
prisma.smsContactListEntry.findMany({
where: {
OR: [
{ phone: { contains: query } },
{ name: { contains: query, mode: 'insensitive' } },
],
},
take: limit,
select: { id: true, phone: true, name: true },
}),
// 2. CRM Contacts (with phones) — exclude opted out
prisma.contact.findMany({
where: {
doNotContact: false,
smsOptOut: false,
OR: [
{ displayName: { contains: query, mode: 'insensitive' } },
{ phone: { contains: query } },
{ phones: { some: { phone: { contains: query } } } },
],
},
take: limit,
select: {
id: true,
displayName: true,
phone: true,
phones: { select: { phone: true }, take: 5 },
},
}),
// 3. Existing conversations
prisma.smsConversation.findMany({
where: {
OR: [
{ phone: { contains: query } },
{ contactName: { contains: query, mode: 'insensitive' } },
],
},
take: limit,
select: { id: true, phone: true, contactName: true, contactId: true, status: true },
}),
]);
// Add SMS contacts first (highest priority)
for (const entry of smsEntries) {
if (!seen.has(entry.phone)) {
seen.set(entry.phone, {
phone: entry.phone,
name: entry.name,
source: 'sms_contact',
sourceId: entry.id,
});
}
}
// Add CRM contacts
for (const contact of crmContacts) {
const phones: string[] = [];
if (contact.phone) phones.push(contact.phone);
for (const cp of contact.phones) {
if (!phones.includes(cp.phone)) phones.push(cp.phone);
}
for (const phone of phones) {
if (!seen.has(phone)) {
seen.set(phone, {
phone,
name: contact.displayName,
source: 'crm_contact',
sourceId: contact.id,
contactId: contact.id,
});
}
}
}
// Add existing conversations (lowest priority)
for (const conv of existingConvs) {
if (!seen.has(conv.phone)) {
seen.set(conv.phone, {
phone: conv.phone,
name: conv.contactName,
source: 'conversation',
sourceId: conv.id,
contactId: conv.contactId || undefined,
});
}
}
return Array.from(seen.values()).slice(0, limit);
},
/**
* Start a new ad-hoc conversation or reuse an existing one, then send the first message.
*/
async startConversation(input: {
phone: string;
message: string;
contactName?: string;
contactId?: string;
}) {
const normalized = normalizePhone(input.phone);
if (!normalized) throw Object.assign(new Error('Invalid phone number'), { statusCode: 400 });
// Use a transaction to prevent race conditions on conversation lookup/create
const conversation = await prisma.$transaction(async (tx) => {
// Look for existing ad-hoc conversation (campaignId = null)
const existing = await tx.smsConversation.findFirst({
where: { phone: normalized, campaignId: null },
});
if (existing) {
if (existing.status === 'OPTED_OUT') {
throw Object.assign(new Error('Cannot message opted-out contact'), { statusCode: 409 });
}
// Reopen if closed, update stats
return tx.smsConversation.update({
where: { id: existing.id },
data: {
status: 'ACTIVE',
totalMessages: { increment: 1 },
lastMessageAt: new Date(),
// Update contact info if provided and not already set
contactName: existing.contactName || input.contactName || undefined,
contactId: existing.contactId || input.contactId || undefined,
},
});
}
// Create new conversation
return tx.smsConversation.create({
data: {
phone: normalized,
contactName: input.contactName || null,
contactId: input.contactId || null,
status: 'ACTIVE',
totalMessages: 1,
lastMessageAt: new Date(),
},
});
});
// Create outbound message
const smsMessage = await prisma.smsMessage.create({
data: {
phone: normalized,
message: input.message,
direction: 'OUTBOUND',
status: 'PENDING',
connectionType: 'termux',
conversationId: conversation.id,
},
});
// Queue the SMS send
await smsQueueService.addSmsJob({
recipientId: smsMessage.id,
campaignId: '',
phone: normalized,
message: input.message,
attemptNumber: 1,
});
// Return full conversation with messages
return this.findById(conversation.id);
},
};

View File

@ -0,0 +1,67 @@
import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsTemplatesService } from './sms-templates.service';
import { createSmsTemplateSchema, updateSmsTemplateSchema } from './sms-templates.schemas';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// GET /api/sms/templates — list with search/filter/pagination
router.get('/', async (req, res, next) => {
try {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 50));
const search = req.query.search as string | undefined;
const category = req.query.category as string | undefined;
const isFavorite = req.query.isFavorite as string | undefined;
const result = await smsTemplatesService.findAll({ page, limit, search, category, isFavorite });
res.json(result);
} catch (err) { next(err); }
});
// GET /api/sms/templates/:id — single template with computed fields
router.get('/:id', async (req, res, next) => {
try {
const template = await smsTemplatesService.findById(req.params.id as string);
if (!template) { res.status(404).json({ error: 'Template not found' }); return; }
res.json(template);
} catch (err) { next(err); }
});
// POST /api/sms/templates — create template
router.post('/', validate(createSmsTemplateSchema), async (req, res, next) => {
try {
const template = await smsTemplatesService.create(req.body, req.user!.id);
res.status(201).json(template);
} catch (err) { next(err); }
});
// PUT /api/sms/templates/:id — update template
router.put('/:id', validate(updateSmsTemplateSchema), async (req, res, next) => {
try {
const template = await smsTemplatesService.update(req.params.id as string, req.body);
res.json(template);
} catch (err) { next(err); }
});
// DELETE /api/sms/templates/:id — delete (system-protected)
router.delete('/:id', async (req, res, next) => {
try {
await smsTemplatesService.delete(req.params.id as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// POST /api/sms/templates/:id/favorite — toggle favorite
router.post('/:id/favorite', async (req, res, next) => {
try {
const template = await smsTemplatesService.toggleFavorite(req.params.id as string);
res.json(template);
} catch (err) { next(err); }
});
export const smsTemplatesRouter = router;

View File

@ -0,0 +1,28 @@
import { z } from 'zod';
export const listSmsTemplatesSchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
search: z.string().optional(),
category: z.string().optional(),
isFavorite: z.enum(['true', 'false']).optional(),
});
export const createSmsTemplateSchema = z.object({
name: z.string().min(1).max(200),
template: z.string().min(1).max(1600),
description: z.string().max(500).nullable().optional(),
category: z.string().max(50).nullable().optional(),
isFavorite: z.boolean().optional(),
});
export const updateSmsTemplateSchema = z.object({
name: z.string().min(1).max(200).optional(),
template: z.string().min(1).max(1600).optional(),
description: z.string().max(500).nullable().optional(),
category: z.string().max(50).nullable().optional(),
isFavorite: z.boolean().optional(),
});
export type CreateSmsTemplateInput = z.infer<typeof createSmsTemplateSchema>;
export type UpdateSmsTemplateInput = z.infer<typeof updateSmsTemplateSchema>;

View File

@ -0,0 +1,152 @@
import { prisma } from '../../../config/database';
import type { CreateSmsTemplateInput, UpdateSmsTemplateInput } from './sms-templates.schemas';
/** Names of templates seeded by the system — cannot be deleted */
const SYSTEM_TEMPLATE_NAMES = ['shift-reminder', 'shift-signup-confirm', 'volunteer-welcome'];
/** Extract {var} placeholder names from a template string */
function extractVariables(template: string): string[] {
const vars: string[] = [];
const regex = /\{(\w+)\}/g;
let match;
while ((match = regex.exec(template)) !== null) {
if (!vars.includes(match[1])) vars.push(match[1]);
}
return vars;
}
export const smsTemplatesService = {
async findAll(params: {
page?: number;
limit?: number;
search?: string;
category?: string;
isFavorite?: string;
}) {
const page = params.page || 1;
const limit = params.limit || 50;
const skip = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (params.search) {
where.OR = [
{ name: { contains: params.search, mode: 'insensitive' } },
{ description: { contains: params.search, mode: 'insensitive' } },
{ template: { contains: params.search, mode: 'insensitive' } },
];
}
if (params.category) {
where.category = params.category;
}
if (params.isFavorite === 'true') {
where.isFavorite = true;
}
const [items, total] = await Promise.all([
prisma.smsMessageTemplate.findMany({
where,
orderBy: [{ isFavorite: 'desc' }, { name: 'asc' }],
skip,
take: limit,
include: {
createdByUser: { select: { id: true, name: true, email: true } },
},
}),
prisma.smsMessageTemplate.count({ where }),
]);
// Enrich with computed fields
const enriched = items.map((t) => ({
...t,
variables: extractVariables(t.template),
isSystem: SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null,
}));
return { items: enriched, total, page, limit };
},
async findById(id: string) {
const t = await prisma.smsMessageTemplate.findUnique({
where: { id },
include: {
createdByUser: { select: { id: true, name: true, email: true } },
},
});
if (!t) return null;
return {
...t,
variables: extractVariables(t.template),
isSystem: SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null,
};
},
async create(data: CreateSmsTemplateInput, userId: string) {
// Check for duplicate name (SmsNotificationService looks up by name)
const existing = await prisma.smsMessageTemplate.findFirst({
where: { name: data.name },
select: { id: true },
});
if (existing) throw new Error('A template with this name already exists');
return prisma.smsMessageTemplate.create({
data: {
name: data.name,
template: data.template,
description: data.description ?? null,
category: data.category ?? null,
isFavorite: data.isFavorite ?? false,
createdByUserId: userId,
},
});
},
async update(id: string, data: UpdateSmsTemplateInput) {
const existing = await prisma.smsMessageTemplate.findUnique({
where: { id },
select: { id: true, name: true },
});
if (!existing) throw new Error('Template not found');
// If renaming, check for duplicate
if (data.name && data.name !== existing.name) {
const dup = await prisma.smsMessageTemplate.findFirst({
where: { name: data.name, NOT: { id } },
select: { id: true },
});
if (dup) throw new Error('A template with this name already exists');
}
return prisma.smsMessageTemplate.update({ where: { id }, data });
},
async delete(id: string) {
const t = await prisma.smsMessageTemplate.findUnique({
where: { id },
select: { name: true, createdByUserId: true },
});
if (!t) throw new Error('Template not found');
if (SYSTEM_TEMPLATE_NAMES.includes(t.name) && t.createdByUserId === null) {
throw new Error('System templates cannot be deleted');
}
await prisma.smsMessageTemplate.delete({ where: { id } });
},
async toggleFavorite(id: string) {
const t = await prisma.smsMessageTemplate.findUnique({
where: { id },
select: { isFavorite: true },
});
if (!t) throw new Error('Template not found');
return prisma.smsMessageTemplate.update({
where: { id },
data: { isFavorite: !t.isFavorite },
});
},
extractVariables,
};

View File

@ -31,6 +31,7 @@ import { mapSettingsRouter } from './modules/map/settings/settings.routes';
import { qrRouter } from './modules/qr/qr.routes';
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes';
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
import { blocksRouter } from './modules/pages/blocks.routes';
@ -83,6 +84,7 @@ import { smsConversationsRouter } from './modules/sms/conversations/sms-conversa
import { smsMessagesRouter } from './modules/sms/messages/sms-messages.routes';
import { smsDeviceRouter } from './modules/sms/device/sms-device.routes';
import { smsSetupRouter } from './modules/sms/setup/sms-setup.routes';
import { smsTemplatesRouter } from './modules/sms/templates/sms-templates.routes';
import { smsQueueService } from './services/sms-queue.service';
import { smsResponseSyncService } from './services/sms-response-sync.service';
import { smsDeviceMonitorService } from './services/sms-device-monitor.service';
@ -99,6 +101,7 @@ import { eventsListPublicRouter } from './modules/events/events-public.routes';
import { homepageRouter } from './modules/homepage/homepage.routes';
import { ogRouter } from './modules/og/og.routes';
import { socialRouter } from './modules/social/social.routes';
import { errorReportRouter } from './modules/reports/error-report.routes';
import { sseService } from './modules/social/sse.service';
import { presenceService } from './modules/social/presence.service';
@ -209,6 +212,8 @@ app.use('/api/map/shifts', shiftsAdminRouter); // Admin shift CRUD (au
app.use('/api/map/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+)
app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT)
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
app.use('/api/qr', qrRouter); // QR code generation (public)
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
@ -246,6 +251,7 @@ app.use('/api/sms/campaigns', smsCampaignsRouter); // SMS campaign C
app.use('/api/sms/conversations', smsConversationsRouter); // SMS conversation threads (ADMIN roles)
app.use('/api/sms/messages', smsMessagesRouter); // SMS message history + ad-hoc send (ADMIN roles)
app.use('/api/sms/device', smsDeviceRouter); // SMS device status + sync trigger (ADMIN roles)
app.use('/api/sms/templates', smsTemplatesRouter); // SMS template CRUD (ADMIN roles)
app.use('/api/sms/setup', smsSetupRouter); // SMS setup wizard (SUPER_ADMIN only)
app.use('/api/profile', profilePublicRouter); // Self-service contact profile (no auth, token-based)
app.use('/api/people', peopleRouter); // People CRM aggregation (ADMIN roles)
@ -256,6 +262,12 @@ app.use('/api/events', eventsListPublicRouter); // Public event
app.use('/api/homepage', homepageRouter); // Public homepage aggregation (no auth, cached)
app.use('/api/og', ogRouter); // OG meta tags for social sharing bots (no auth, cached)
app.use('/api/social', socialRouter); // Social connections (auth required)
app.use('/api/public/error-report', errorReportRouter); // Public 404 error reporting (rate-limited)
// --- API 404 Handler (catch unmatched /api/* routes) ---
app.use('/api/*', (_req, res) => {
res.status(404).json({ error: { message: 'Route not found', code: 'NOT_FOUND' } });
});
// --- Error Handler (must be last) ---
app.use(errorHandler);

@ -1 +1 @@
Subproject commit 2457662e12b5fd4c2e62a22503f3ffd93dc5e303
Subproject commit d9be9c961d4ffcf32abac81fd32589abfb146fd3

View File

@ -91,6 +91,14 @@ LISTMONK_API_TOKEN={{secrets.listmonkApiToken}}
LISTMONK_ADMIN_USER=v2-api
LISTMONK_ADMIN_PASSWORD={{secrets.listmonkApiToken}}
LISTMONK_PROXY_PORT=9002
LISTMONK_WEBHOOK_SECRET=
LISTMONK_DB_PORT=5434
LISTMONK_SMTP_HOST={{containerPrefix}}-mailhog
LISTMONK_SMTP_PORT=1025
LISTMONK_SMTP_USER=
LISTMONK_SMTP_PASSWORD=
LISTMONK_SMTP_TLS_TYPE=none
LISTMONK_SMTP_FROM={{name}} <noreply@{{domain}}>
# Media
{{#if enableMedia}}
@ -102,6 +110,13 @@ MEDIA_API_PORT=4100
MEDIA_ROOT=/media/local
MEDIA_UPLOADS=/media/uploads
MAX_UPLOAD_SIZE_GB=10
PUBLIC_MEDIA_PORT=3100
VIDEO_PLAYER_DEBUG=false
VIDEO_ANALYTICS_RETENTION_DAYS=90
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
VIDEO_SCHEDULE_DEFAULT_TIMEZONE=UTC
VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
# NAR Data
NAR_DATA_DIR=/data
@ -109,21 +124,35 @@ NAR_DATA_DIR=/data
# Platform Service URLs (used for health checks)
MINI_QR_URL=http://{{containerPrefix}}-mini-qr:8080
EXCALIDRAW_URL=http://{{containerPrefix}}-excalidraw:80
EXCALIDRAW_WS_URL=wss://draw.{{domain}}
HOMEPAGE_URL=http://{{containerPrefix}}-homepage:3000
VAULTWARDEN_URL=http://{{containerPrefix}}-vaultwarden:80
VAULTWARDEN_ADMIN_TOKEN={{secrets.vaultwardenAdminToken}}
VAULTWARDEN_DOMAIN=https://vault.{{domain}}
VAULTWARDEN_SIGNUPS_ALLOWED=false
VAULTWARDEN_WEBSOCKET_ENABLED=true
VAULTWARDEN_SMTP_SECURITY=off
# Geocoding
MAPBOX_API_KEY=
GOOGLE_MAPS_API_KEY=
GOOGLE_MAPS_ENABLED=false
GEOCODING_RATE_LIMIT_MS=1100
GEOCODING_CACHE_ENABLED=true
GEOCODING_CACHE_TTL_HOURS=24
GEOCODING_PARALLEL_ENABLED=true
GEOCODING_BATCH_SIZE=10
BULK_GEOCODE_ENABLED=true
BULK_GEOCODE_MAX_BATCH=5000
# Represent API
REPRESENT_API_URL=https://represent.opennorth.ca
# Overpass / Area Import
OVERPASS_API_URL=https://overpass-api.de/api/interpreter
OVERPASS_MIN_DELAY_MS=30000
AREA_IMPORT_MAX_GRID_POINTS=500
# Pangolin Tunnel
PANGOLIN_API_URL=
PANGOLIN_API_KEY=
@ -205,18 +234,42 @@ GRAFANA_ADMIN_PASSWORD={{secrets.grafanaAdminPassword}}
GRAFANA_ROOT_URL=https://grafana.{{domain}}
PROMETHEUS_PORT=9090
GRAFANA_PORT=3000
CADVISOR_PORT=8086
NODE_EXPORTER_PORT=9100
REDIS_EXPORTER_PORT=9121
ALERTMANAGER_PORT=9093
ALERTMANAGER_EMBED_PORT={{math ports.embed "+" 16}}
GOTIFY_PORT=8889
GOTIFY_ADMIN_USER=admin
GOTIFY_ADMIN_PASSWORD=admin
# MkDocs
MKDOCS_PORT={{math ports.embed "+" 8}}
MKDOCS_SITE_SERVER_PORT={{math ports.embed "+" 14}}
MKDOCS_PREVIEW_URL=http://{{containerPrefix}}-mkdocs:8000
MKDOCS_DOCS_PATH=/mkdocs/docs
CODE_SERVER_PORT={{math ports.embed "+" 7}}
CODE_SERVER_URL=http://{{containerPrefix}}-code-server:8080
USER_NAME=coder
BASE_DOMAIN=https://{{domain}}
# Gitea
GITEA_URL=http://{{containerPrefix}}-gitea:3000
GITEA_SSH_PORT=2222
GITEA_DB_TYPE=mysql
GITEA_DB_HOST={{containerPrefix}}-gitea-db:3306
GITEA_DB_NAME=gitea
GITEA_DB_USER=gitea
GITEA_DB_PASSWD={{secrets.giteaAdminPassword}}
GITEA_DB_ROOT_PASSWORD={{secrets.giteaAdminPassword}}
GITEA_ROOT_URL=https://git.{{domain}}
GITEA_DOMAIN=git.{{domain}}
GITEA_COMMENTS_ENABLED=false
GITEA_API_TOKEN=
GITEA_COMMENTS_REPO_OWNER=
GITEA_COMMENTS_REPO_NAME=docs-comments
GITEA_OAUTH_CLIENT_ID=
GITEA_OAUTH_CLIENT_SECRET=
# n8n
N8N_HOST=n8n.{{domain}}
@ -224,12 +277,17 @@ N8N_URL=http://{{containerPrefix}}-n8n:5678
N8N_ENCRYPTION_KEY={{secrets.n8nEncryptionKey}}
N8N_USER_EMAIL={{secrets.adminEmail}}
N8N_USER_PASSWORD={{secrets.nocodbAdminPassword}}
GENERIC_TIMEZONE=UTC
# MailHog
MAILHOG_URL=http://{{containerPrefix}}-mailhog:8025
MAILHOG_SMTP_PORT=1025
MAILHOG_WEB_PORT=8025
# Homepage
HOMEPAGE_PORT=3010
HOMEPAGE_VAR_BASE_URL=http://localhost
# Dev Tools
{{#if enableDevTools}}
ENABLE_DEV_TOOLS=true
@ -251,6 +309,11 @@ VITE_MKDOCS_URL=http://{{containerPrefix}}-mkdocs:8000
VITE_MEDIA_API_URL=http://{{containerPrefix}}-media-api:4100
{{/if}}
# Bunker Ops (Fleet Management)
INSTANCE_LABEL={{slug}}
BUNKER_OPS_ENABLED=false
BUNKER_OPS_REMOTE_WRITE_URL=
# Embed proxy ports (nginx proxy for iframe embedding in admin GUI)
NOCODB_EMBED_PORT={{math ports.embed "+" 0}}
N8N_EMBED_PORT={{math ports.embed "+" 1}}

View File

@ -0,0 +1,189 @@
/**
* Scheduling Poll Block Hydration for MkDocs
*
* Scans for .scheduling-poll-block elements, fetches poll data from the API,
* and renders a read-only poll summary with a "Vote Now" link to the full
* interactive voting page on the app.
*
* Follows the gancio-events.js hydration pattern.
*/
(function () {
'use strict';
function getApiUrl() {
// env-config.js injects these globals
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 STATUS_COLORS = {
OPEN: '#52c41a',
CLOSED: '#fa8c16',
FINALIZED: '#1890ff',
CANCELLED: '#ff4d4f',
};
var STATUS_LABELS = {
OPEN: 'Open for Voting',
CLOSED: 'Closed',
FINALIZED: 'Date Confirmed',
CANCELLED: 'Cancelled',
};
function formatDate(dateStr) {
var d = new Date(dateStr + 'T00:00:00');
var days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
return days[d.getDay()] + ', ' + months[d.getMonth()] + ' ' + d.getDate();
}
function hydrateBlocks() {
var blocks = document.querySelectorAll('.scheduling-poll-block');
if (blocks.length === 0) return;
var apiUrl = getApiUrl();
var appUrl = getAppUrl();
blocks.forEach(function (block) {
// Skip if already hydrated
if (block.getAttribute('data-hydrated') === 'true') return;
var slug = block.getAttribute('data-poll-slug');
if (!slug) return;
var showComments = block.getAttribute('data-show-comments') !== 'false';
var title = block.getAttribute('data-title') || '';
block.setAttribute('data-hydrated', 'true');
block.innerHTML = '<div style="text-align:center; padding:20px; opacity:0.6;">Loading poll...</div>';
fetch(apiUrl + '/api/meeting-planner/public/' + encodeURIComponent(slug))
.then(function (res) {
if (!res.ok) throw new Error('Poll not found');
return res.json();
})
.then(function (poll) {
var statusColor = STATUS_COLORS[poll.status] || '#666';
var statusLabel = STATUS_LABELS[poll.status] || poll.status;
var isFinalized = poll.status === 'FINALIZED';
var options = poll.options || [];
var bestScore = 0;
options.forEach(function (o) {
if ((o.score || 0) > bestScore) bestScore = o.score || 0;
});
var html = '';
// Title
if (title) {
html += '<h2 style="text-align:center; margin:0 0 8px; font-size:1.5rem;">' + title + '</h2>';
}
// Poll title + status
html += '<h3 style="margin:0 0 8px; font-size:1.2rem;">' + poll.title + '</h3>';
if (poll.description) {
html += '<p style="opacity:0.75; margin:0 0 8px; line-height:1.5;">' + poll.description + '</p>';
}
html += '<div style="margin-bottom:12px;">';
html += '<span style="display:inline-block; padding:2px 10px; border-radius:4px; font-size:12px; font-weight:600; background:' + statusColor + '22; color:' + statusColor + '; border:1px solid ' + statusColor + '44;">' + statusLabel + '</span>';
if (poll.location) {
html += ' <span style="font-size:13px; opacity:0.65; margin-left:8px;">' + poll.location + '</span>';
}
html += '</div>';
// Finalized banner
if (isFinalized && poll.finalizedOption) {
html += '<div style="padding:10px 14px; border-radius:6px; background:rgba(82,196,26,0.1); border:1px solid rgba(82,196,26,0.3); margin-bottom:12px; color:#52c41a;">';
html += '<strong>Confirmed:</strong> ' + formatDate(poll.finalizedOption.date) + ' &mdash; ' + poll.finalizedOption.startTime + '&ndash;' + poll.finalizedOption.endTime;
html += '</div>';
}
// Options table
if (options.length > 0) {
html += '<div style="overflow-x:auto; margin-bottom:12px;">';
html += '<table style="width:100%; border-collapse:collapse; font-size:13px;">';
html += '<thead><tr>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:left;">Date / Time</th>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">Yes</th>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">If Need Be</th>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">No</th>';
html += '<th style="padding:8px 12px; border-bottom:2px solid rgba(255,255,255,0.15); text-align:center;">Score</th>';
html += '</tr></thead><tbody>';
options.forEach(function (opt) {
var isBest = bestScore > 0 && (opt.score || 0) === bestScore;
var isConfirmed = isFinalized && poll.finalizedOptionId === opt.id;
var rowBg = isConfirmed ? 'rgba(82,196,26,0.08)' : isBest ? 'rgba(82,196,26,0.04)' : '';
html += '<tr style="background:' + rowBg + ';">';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1);">';
html += '<strong>' + formatDate(opt.date) + '</strong><br><span style="font-size:11px; opacity:0.7;">' + opt.startTime + '&ndash;' + opt.endTime + '</span>';
if (isConfirmed) html += ' <span style="color:#52c41a; font-size:10px; font-weight:600;">&#10003;</span>';
html += '</td>';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#52c41a;">' + (opt.yesCount || 0) + '</td>';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#faad14;">' + (opt.ifNeedBeCount || 0) + '</td>';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; color:#d9d9d9;">' + (opt.noCount || 0) + '</td>';
html += '<td style="padding:8px 12px; border-bottom:1px solid rgba(255,255,255,0.1); text-align:center; font-weight:600;">' + (opt.score || 0) + '</td>';
html += '</tr>';
});
html += '</tbody></table></div>';
}
// Comments count
if (showComments && poll.comments && poll.comments.length > 0) {
html += '<p style="font-size:13px; opacity:0.65;">' + poll.comments.length + ' comment' + (poll.comments.length !== 1 ? 's' : '') + '</p>';
}
// Vote Now CTA
if (poll.status === 'OPEN') {
html += '<div style="text-align:center; margin-top:16px;">';
html += '<a href="' + appUrl + '/poll/' + encodeURIComponent(slug) + '" target="_blank" rel="noopener noreferrer" ';
html += 'style="display:inline-block; padding:12px 32px; background:#fa8c16; color:#fff; text-decoration:none; border-radius:6px; font-weight:600; font-size:14px;">';
html += 'Vote Now &rarr;</a></div>';
}
block.innerHTML = '<div style="max-width:700px; margin:0 auto;">' + html + '</div>';
})
.catch(function () {
block.innerHTML = '<div style="text-align:center; padding:24px; opacity:0.5;">' +
'<p>Poll unavailable</p>' +
'<a href="' + appUrl + '/poll/' + encodeURIComponent(slug) + '" target="_blank" rel="noopener noreferrer" style="color:#fa8c16;">View poll &rarr;</a>' +
'</div>';
});
});
}
// Initial hydration
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', hydrateBlocks);
} else {
hydrateBlocks();
}
// Re-hydrate on MkDocs SPA navigation
if (typeof document$ !== 'undefined') {
document$.subscribe(function () {
setTimeout(hydrateBlocks, 100);
});
}
})();

View File

@ -95,10 +95,23 @@ The setup script automatically:
- Saves the API key to `~/.bashrc`
- Requests SMS and Contacts permissions (tap **Allow** when prompted)
- Creates a Termux:Boot auto-start script (if Termux:Boot is installed)
- Starts the SMS server with the watchdog (auto-restarts on crash)
- Starts the SMS server
When done, note the **Phone URL** displayed (e.g. `http://100.64.0.5:5001`).
#### Recommended: Install Service Supervisor
After initial setup, install `termux-services` for reliable process management. This uses runit, a proper UNIX service supervisor that automatically restarts the server if it crashes:
```bash
cd ~/sms-server && bash android/setup-services.sh
```
This registers two supervised services:
- **sms-api** — Flask SMS API server (port 5001)
- **sshd-custom** — SSH daemon for remote management (port 8022)
### Step 4: Prevent Android from Killing Termux
This is **required** for the server to run reliably in the background:
@ -115,17 +128,35 @@ To pull the latest server code and re-run setup:
cd ~/sms-server && git pull && bash android/setup.sh YOUR_API_KEY_HERE
```
### Manual Control
### Service Management
If you installed `termux-services` (recommended):
```bash
# Check if the server is running
curl http://127.0.0.1:5001/health
# Check status
sv status sms-api
# Restart
sv restart sms-api
# Stop
sv down sms-api
# Start
sv up sms-api
# View logs
tail -f ~/logs/sms-api.log
# Stop the server
pkill -f sms-watchdog.sh && pkill -f termux-sms-api-server.py
# Health check
curl http://127.0.0.1:5001/health
```
Without `termux-services` (legacy watchdog):
```bash
# Check if the server is running
curl http://127.0.0.1:5001/health
# Restart manually
cd ~/sms-server/android && bash sms-watchdog.sh
@ -353,8 +384,8 @@ export SMS_API_SECRET='correct-key-from-admin-panel'
echo 'export SMS_API_SECRET="correct-key-from-admin-panel"' >> ~/.bashrc
# Restart the server
pkill -f termux-sms-api-server.py
cd ~/sms-server/android && python termux-sms-api-server.py
sv restart sms-api
# Or without termux-services: pkill -f termux-sms-api-server.py && cd ~/sms-server/android && python termux-sms-api-server.py
```
### SMS not sending
@ -372,12 +403,13 @@ cd ~/sms-server/android && python termux-sms-api-server.py
**Symptoms:** Server stops after some time, especially when phone screen is off.
**Fix:** Disable battery optimization for Termux:
**Fix:**
1. Android Settings → Apps → Termux → Battery → **Unrestricted**
2. Lock Termux in recent apps (long-press app card → Lock)
3. Some phones: Settings → Battery → Battery Optimization → find Termux → Don't Optimize
4. Samsung: Settings → Device Care → Battery → App Power Management → add Termux to "Never sleeping apps"
1. **Install `termux-services`** (if not already): `bash ~/sms-server/android/setup-services.sh` — this uses runit, a proper service supervisor that auto-restarts the server immediately if it crashes
2. **Disable battery optimization:** Android Settings → Apps → Termux → Battery → **Unrestricted**
3. **Lock Termux in recent apps** — long-press the app card → Lock/Pin
4. Samsung: also add Termux, Termux:API, and Termux:Boot to **Settings → Device Care → Battery → Never Sleeping Apps**
5. **Acquire wake lock:** Run `termux-wake-lock` in Termux (included in boot script)
### Server won't start — "Missing SMS_API_SECRET"
@ -425,6 +457,6 @@ cd ~/sms-server
git pull
# Restart the server
pkill -f termux-sms-api-server.py
cd android && python termux-sms-api-server.py
sv restart sms-api
# Or without termux-services: pkill -f termux-sms-api-server.py && cd android && python termux-sms-api-server.py
```

View File

@ -183,6 +183,7 @@ Listmonk handles newsletter/marketing campaigns. Sync with the main platform is
| `LISTMONK_ADMIN_USER` | `v2-api` | Same as `LISTMONK_API_USER` (used by the sync service). |
| `LISTMONK_ADMIN_PASSWORD` | &mdash; | Same as `LISTMONK_API_TOKEN`. |
| `LISTMONK_SYNC_ENABLED` | `false` | :material-flask: Set to `true` to sync participants/locations/users to Listmonk lists. |
| `LISTMONK_WEBHOOK_SECRET` | *(empty)* | Shared secret for Listmonk webhook callbacks. |
| `LISTMONK_PROXY_PORT` | `9002` | Nginx proxy port for Listmonk. |
??? example "Listmonk SMTP settings"
@ -253,6 +254,7 @@ Self-hosted Git repository. Optional service.
| Variable | Default | Description |
|----------|---------|-------------|
| `GITEA_URL` | `http://gitea-changemaker:3000` | Internal container URL for Gitea. |
| `GITEA_PORT` / `GITEA_WEB_PORT` | `3030` | Gitea web UI port. |
| `GITEA_SSH_PORT` | `2222` | Gitea SSH port for git operations. |
| `GITEA_DB_TYPE` | `mysql` | Database type (Gitea uses its own MySQL). |
@ -264,6 +266,18 @@ Self-hosted Git repository. Optional service.
| `GITEA_ROOT_URL` | `https://git.cmlite.org` | Public-facing URL for Gitea. |
| `GITEA_DOMAIN` | `git.cmlite.org` | Domain used in git clone URLs. |
??? example "Gitea Docs Comments"
Enable comments on MkDocs documentation pages, backed by Gitea Issues.
| Variable | Default | Description |
|----------|---------|-------------|
| `GITEA_COMMENTS_ENABLED` | `false` | :material-flask: Enable comments on MkDocs pages. |
| `GITEA_API_TOKEN` | *(empty)* | Personal access token with repo write scope. Create in Gitea &rarr; Settings &rarr; Applications. |
| `GITEA_COMMENTS_REPO_OWNER` | *(empty)* | Gitea username that owns the docs-comments repo. |
| `GITEA_COMMENTS_REPO_NAME` | `docs-comments` | Repository name (auto-created via admin setup). |
| `GITEA_OAUTH_CLIENT_ID` | *(empty)* | OAuth2 application client ID (create in Gitea &rarr; Settings &rarr; Applications &rarr; OAuth2). |
| `GITEA_OAUTH_CLIENT_SECRET` | *(empty)* | OAuth2 application client secret. |
---
## n8n (Workflow Automation) :material-tune-variant:
@ -382,6 +396,48 @@ Self-hosted event management platform. Uses the shared PostgreSQL database (auto
---
## Jitsi Meet (Video Conferencing) :material-flask:
Self-hosted video conferencing with JWT authentication. Integrates with Rocket.Chat for in-channel video calls.
| Variable | Default | Description |
|----------|---------|-------------|
| `ENABLE_MEET` | `false` | :material-flask: Set to `true` to enable the Jitsi Meet integration. The initial default; once saved in admin Settings, the DB value is authoritative. |
| `JITSI_APP_ID` | `changemaker` | JWT application ID. Must match across Jitsi Prosody, Rocket.Chat app settings, and `JWT_ACCEPTED_ISSUERS`/`JWT_ACCEPTED_AUDIENCES`. |
| `JITSI_APP_SECRET` | &mdash; | :material-alert-circle:{ .text-red } JWT secret for signing Jitsi tokens. Generate with `openssl rand -hex 32`. Shared between Jitsi Prosody, Rocket.Chat, and the API. |
| `JITSI_JICOFO_AUTH_PASSWORD` | &mdash; | Internal XMPP password for Jicofo (conference focus). Generate with `openssl rand -hex 16`. |
| `JITSI_JVB_AUTH_PASSWORD` | &mdash; | Internal XMPP password for JVB (video bridge). Generate with `openssl rand -hex 16`. |
| `JITSI_EMBED_PORT` | `8893` | Port for iframe embedding in admin. |
| `JITSI_URL` | `http://jitsi-web-changemaker:80` | Internal container URL. |
| `JVB_ADVERTISE_IP` | *(empty)* | Server's public IP address. **Required in production** for NAT traversal so remote participants can connect. |
| `JVB_PORT` | `10000` | UDP port for media traffic. Must be open in your firewall. |
!!! warning "Production requirements"
- `JVB_ADVERTISE_IP` must be set to your server's public IP for calls to work outside the local network.
- Port `10000/udp` must be open in your firewall for media traffic.
- Calls must go through the production domain (not localhost) for SSL/JWT to work.
---
## SMS Campaigns (Termux Android Bridge) :material-flask:
Send SMS messages via an Android phone running the Termux API server. The phone acts as an SMS gateway.
| Variable | Default | Description |
|----------|---------|-------------|
| `ENABLE_SMS` | `false` | :material-flask: Set to `true` to enable SMS campaigns. The initial default; once saved in admin Settings, the DB value is authoritative. |
| `TERMUX_API_URL` | `http://10.0.0.193:5001` | URL of the Termux API server running on the Android phone. |
| `TERMUX_API_KEY` | *(empty)* | API key for authenticating with the Termux server (HMAC auth via `X-API-Key` header). |
| `SMS_DELAY_BETWEEN_MS` | `3000` | Delay between sending individual SMS messages (ms). Prevents carrier throttling. |
| `SMS_MAX_RETRIES` | `3` | Maximum retry attempts for failed SMS sends. |
| `SMS_RESPONSE_SYNC_INTERVAL_MS` | `30000` | How often to poll the phone's inbox for responses (ms). |
| `SMS_DEVICE_MONITOR_INTERVAL_MS` | `30000` | How often to check device health &mdash; battery, connectivity (ms). |
!!! tip "GUI configuration"
The Termux API URL and API key can also be configured from **Admin &rarr; Settings &rarr; SMS**. Database values override these env vars when set.
---
## MailHog (Development Email)
| Variable | Default | Description |
@ -474,6 +530,20 @@ docker compose --profile monitoring up -d
| `GOTIFY_PORT` | `8889` | Gotify push notification port. |
| `GOTIFY_ADMIN_USER` | `admin` | Gotify admin username. |
| `GOTIFY_ADMIN_PASSWORD` | `admin` | :material-tune-variant: Change in production. |
| `GRAFANA_EMBED_PORT` | `8894` | Port for iframe embedding Grafana in admin. |
| `ALERTMANAGER_EMBED_PORT` | `8895` | Port for iframe embedding Alertmanager in admin. |
---
## Bunker Ops (Fleet Management) :material-flask:
Remote metrics push for managing multiple Changemaker Lite instances from a central monitoring server.
| Variable | Default | Description |
|----------|---------|-------------|
| `INSTANCE_LABEL` | *(empty)* | Unique label for this instance (used as a Prometheus metric label). Falls back to `DOMAIN` if empty. |
| `BUNKER_OPS_ENABLED` | `false` | :material-flask: Enable remote metrics push to a central VictoriaMetrics server. |
| `BUNKER_OPS_REMOTE_WRITE_URL` | *(empty)* | VictoriaMetrics `remote_write` endpoint (e.g., `https://ops.example.com/api/v1/write`). |
---
@ -516,6 +586,11 @@ echo "ROCKETCHAT_ADMIN_PASSWORD=$(openssl rand -hex 16)"
# Gancio
echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
# Jitsi Meet
echo "JITSI_APP_SECRET=$(openssl rand -hex 32)"
echo "JITSI_JICOFO_AUTH_PASSWORD=$(openssl rand -hex 16)"
echo "JITSI_JVB_AUTH_PASSWORD=$(openssl rand -hex 16)"
```
!!! tip
@ -551,6 +626,8 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
ENABLE_MEDIA_FEATURES=true
ENABLE_PAYMENTS=true
ENABLE_CHAT=true
ENABLE_MEET=true
ENABLE_SMS=true
LISTMONK_SYNC_ENABLED=true
GANCIO_SYNC_ENABLED=true
LISTMONK_DB_PASSWORD=...
@ -564,6 +641,10 @@ echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"
VAULTWARDEN_ADMIN_TOKEN=...
ROCKETCHAT_ADMIN_PASSWORD=...
GANCIO_ADMIN_PASSWORD=...
JITSI_APP_SECRET=...
JITSI_JICOFO_AUTH_PASSWORD=...
JITSI_JVB_AUTH_PASSWORD=...
JVB_ADVERTISE_IP=your.public.ip.here
EMAIL_TEST_MODE=false
SMTP_HOST=smtp.your-provider.com
SMTP_PORT=587

View File

@ -1683,6 +1683,136 @@
.btn-primary:focus-visible, .btn-secondary:focus-visible {
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.4);
}
/* ============================================
FREE ASTERISK MODAL
============================================ */
.free-asterisk {
color: var(--primary-light);
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
cursor: pointer;
transition: color var(--transition);
}
.free-asterisk:hover {
color: #C084FC;
}
.free-modal-backdrop {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
transition: opacity 0.25s ease, visibility 0.25s ease;
}
.free-modal-backdrop.active {
opacity: 1;
visibility: visible;
}
.free-modal {
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
max-width: 520px;
width: 90%;
padding: 2rem;
position: relative;
transform: translateY(20px) scale(0.97);
transition: transform 0.25s ease;
}
.free-modal-backdrop.active .free-modal {
transform: translateY(0) scale(1);
}
.free-modal-close {
position: absolute;
top: 1rem;
right: 1rem;
background: none;
border: none;
color: var(--text-muted);
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
transition: color var(--transition);
}
.free-modal-close:hover {
color: var(--text-primary);
}
.free-modal h3 {
font-size: 1.25rem;
color: var(--text-primary);
margin-bottom: 0.5rem;
}
.free-modal .free-modal-intro {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1.25rem;
line-height: 1.6;
}
.free-modal-list {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.free-modal-list li {
display: flex;
align-items: flex-start;
gap: 0.75rem;
font-size: 0.9rem;
color: var(--text-secondary);
line-height: 1.5;
}
.free-modal-list .dep-icon {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--myc-node-bg);
border: 1px solid var(--myc-node-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
margin-top: 1px;
}
.free-modal-list strong {
color: var(--text-primary);
font-weight: 600;
}
.free-modal-footer {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--border-color);
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1.5;
}
@media (max-width: 480px) {
.free-modal {
max-width: 95%;
padding: 1.5rem;
}
}
</style>
<meta property="og:type" content="website" />
@ -1795,7 +1925,7 @@
<p class="hero-subtitle">
Run your campaigns, canvassing, fundraising, team chat, media, and more &mdash; all on your own infrastructure.
No corporate surveillance. No foreign interference. No monthly ransoms.
A free and open source toolkit built for growing political movements.
A <a href="#" class="free-asterisk" id="free-asterisk-link">free*</a> and open source toolkit built for growing political movements.
</p>
<div class="hero-cta">
<a href="mailto:cmlite@bnkops.ca?subject=Request%20to%20Chat%20-%20CMLITE&body=Hi%20CMlite%20Team%2C%20I%20would%20like%20to%20chat!%20Please%20send%20me%20a%20email%20back.%20Cheers%2C%20" class="btn-primary">Schedule a Chat <span aria-hidden="true">&rarr;</span></a>
@ -2649,6 +2779,50 @@
</div>
</footer>
<!-- ============================================
FREE* MODAL
============================================ -->
<div class="free-modal-backdrop" id="free-modal-backdrop">
<div class="free-modal" role="dialog" aria-labelledby="free-modal-title" aria-modal="true">
<button class="free-modal-close" id="free-modal-close" aria-label="Close">&times;</button>
<h3 id="free-modal-title">What does free* mean?</h3>
<p class="free-modal-intro">
Changemaker Lite is 100% free and open source software &mdash; no license fees, no subscriptions, no vendor lock-in.
Running it in production does require a few external dependencies:
</p>
<ul class="free-modal-list">
<li>
<span class="dep-icon">&#x1F5A5;</span>
<span><strong>A Linux server or hardware</strong> &mdash; something to run the stack on (old laptop, mini PC, VPS)</span>
</li>
<li>
<span class="dep-icon">&#x1F310;</span>
<span><strong>An internet connection</strong> &mdash; to serve traffic to the public</span>
</li>
<li>
<span class="dep-icon">&#x1F3F7;</span>
<span><strong>A domain name</strong> &mdash; ~$10&ndash;15/yr for a custom domain</span>
</li>
<li>
<span class="dep-icon">&#x1F517;</span>
<span><strong>A production URL / tunnel</strong> &mdash; for public-facing deployment (can be deployed privately without one)</span>
</li>
<li>
<span class="dep-icon">&#x2709;</span>
<span><strong>An SMTP email provider</strong> &mdash; free tiers exist, but the most capable and secure are paid</span>
</li>
<li>
<span class="dep-icon">&#x1F4F1;</span>
<span><strong>An Android phone</strong> &mdash; required for SMS campaigns (uses Termux as a bridge to send texts)</span>
</li>
</ul>
<p class="free-modal-footer">
None of these are unique to Changemaker Lite &mdash; any self-hosted platform needs them. The software itself will always be free.
Self-host at no cost, or pay for a pre-configured hardware device or a managed deployment.
</p>
</div>
</div>
<!-- ============================================
SCRIPTS
============================================ -->
@ -3358,6 +3532,26 @@
}
};
/* ===========================================
FREE* MODAL
=========================================== */
const FreeModal = {
init() {
const link = document.getElementById('free-asterisk-link');
const backdrop = document.getElementById('free-modal-backdrop');
const closeBtn = document.getElementById('free-modal-close');
if (!link || !backdrop || !closeBtn) return;
const open = () => backdrop.classList.add('active');
const close = () => backdrop.classList.remove('active');
link.addEventListener('click', (e) => { e.preventDefault(); open(); });
closeBtn.addEventListener('click', close);
backdrop.addEventListener('click', (e) => { if (e.target === backdrop) close(); });
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && backdrop.classList.contains('active')) close(); });
}
};
/* ===========================================
BOOT
=========================================== */
@ -3370,6 +3564,7 @@
RootNetwork.init();
FloatingElements.init();
SmoothScroll.init();
FreeModal.init();
initSearch();
});

View File

@ -87,6 +87,7 @@ extra_javascript:
- assets/js/image-gallery.js
- assets/js/gancio-events.js
- assets/js/payment-widgets.js
- assets/js/scheduling-poll.js
- javascripts/ad-widgets.js
- javascripts/docs-comments.js