Tonne of things

This commit is contained in:
bunker-admin 2026-03-08 18:11:26 -06:00
parent 3f35e4b18d
commit 76b87d9f3d
208 changed files with 4280 additions and 1416 deletions

View File

@ -343,7 +343,7 @@ SMS_DEVICE_MONITOR_INTERVAL_MS=300000
# --- Monitoring (only used with --profile monitoring) ---
PROMETHEUS_PORT=9090
GRAFANA_PORT=3005
GRAFANA_ADMIN_PASSWORD=admin
GRAFANA_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
GRAFANA_ROOT_URL=http://localhost:3005
CADVISOR_PORT=8086
NODE_EXPORTER_PORT=9100
@ -351,7 +351,7 @@ REDIS_EXPORTER_PORT=9121
ALERTMANAGER_PORT=9093
GOTIFY_PORT=8889
GOTIFY_ADMIN_USER=admin
GOTIFY_ADMIN_PASSWORD=admin
GOTIFY_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# --- Bunker Ops (Fleet Management) ---
INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN)

View File

@ -41,8 +41,10 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker
- **JWT-based auth:** access tokens (15min) + refresh tokens (7 days, stored in DB)
- **Password policy:** 12+ characters, uppercase, lowercase, digit (enforced at schema level)
- **Initial admin:** Configured via `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD` env vars (auto-created during database seeding)
- **Roles:** `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `USER`, `TEMP`
- **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware
- **Roles:** `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `BROADCAST_ADMIN`, `CONTENT_ADMIN`, `MEDIA_ADMIN`, `PAYMENTS_ADMIN`, `EVENTS_ADMIN`, `SOCIAL_ADMIN`, `USER`, `TEMP`
- **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware; `SUPER_ADMIN` implicitly bypasses all role checks
- **Module-specific role groups** (defined in `api/src/utils/roles.ts`): `INFLUENCE_ROLES`, `MAP_ROLES`, `BROADCAST_ROLES`, `CONTENT_ROLES`, `MEDIA_ROLES`, `PAYMENTS_ROLES`, `EVENTS_ROLES`, `SOCIAL_ROLES`, `SYSTEM_ROLES`, `SCHEDULING_ROLES`
- **User management:** `SUPER_ADMIN` always; other admins need `permissions.canManageUsers: true` for write operations
- **Security features:**
- Refresh token rotation (atomic transaction)
- User enumeration prevention (401 not 404)

132
admin/package-lock.json generated
View File

@ -11,6 +11,7 @@
"@ant-design/icons": "^5.6.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@dagrejs/dagre": "^2.0.4",
"@hocuspocus/provider": "^3.4.4",
"@monaco-editor/react": "^4.7.0",
"@types/d3-force": "^3.0.10",
"@types/dompurify": "^3.2.0",
@ -42,7 +43,9 @@
"react-leaflet-cluster": "^4.0.0",
"react-router-dom": "^7.1.1",
"recharts": "^3.7.0",
"y-monaco": "^0.1.6",
"yaml": "^2.8.2",
"yjs": "^13.6.29",
"zustand": "^5.0.3"
},
"devDependencies": {
@ -868,6 +871,29 @@
"node": ">=18"
}
},
"node_modules/@hocuspocus/common": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-3.4.4.tgz",
"integrity": "sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA==",
"dependencies": {
"lib0": "^0.2.87"
}
},
"node_modules/@hocuspocus/provider": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-3.4.4.tgz",
"integrity": "sha512-KbsMAfdYcIJD8eMU/5QnpXcSOvIWAcCNI33FSRSaKCIpYBFtAwkYIwWnZJmPZ8a1BMAtqQc+uvy9+UQf7GHnGQ==",
"dependencies": {
"@hocuspocus/common": "^3.4.4",
"@lifeomic/attempt": "^3.0.2",
"lib0": "^0.2.87",
"ws": "^8.17.1"
},
"peerDependencies": {
"y-protocols": "^1.0.6",
"yjs": "^13.6.8"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@ -913,6 +939,11 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lifeomic/attempt": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz",
"integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw=="
},
"node_modules/@monaco-editor/loader": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
@ -2629,6 +2660,15 @@
"node": ">=12"
}
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@ -2690,6 +2730,26 @@
"leaflet": "^1.3.1"
}
},
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@ -3870,6 +3930,62 @@
}
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/y-monaco": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/y-monaco/-/y-monaco-0.1.6.tgz",
"integrity": "sha512-sYRywMmcylt+Nupl+11AvizD2am06ST8lkVbUXuaEmrtV6Tf+TD4rsEm6u9YGGowYue+Vfg1IJ97SUP2J+PVXg==",
"dependencies": {
"lib0": "^0.2.43"
},
"engines": {
"node": ">=12.0.0",
"npm": ">=6.0.0"
},
"peerDependencies": {
"monaco-editor": ">=0.20.0",
"yjs": "^13.3.1"
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"peer": true,
"dependencies": {
"lib0": "^0.2.85"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@ -3891,6 +4007,22 @@
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yjs": {
"version": "13.6.29",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/zustand": {
"version": "5.0.11",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",

View File

@ -12,6 +12,7 @@
"@ant-design/icons": "^5.6.0",
"@ant-design/v5-patch-for-react-19": "^1.0.3",
"@dagrejs/dagre": "^2.0.4",
"@hocuspocus/provider": "^3.4.4",
"@monaco-editor/react": "^4.7.0",
"@types/d3-force": "^3.0.10",
"@types/dompurify": "^3.2.0",
@ -43,7 +44,9 @@
"react-leaflet-cluster": "^4.0.0",
"react-router-dom": "^7.1.1",
"recharts": "^3.7.0",
"y-monaco": "^0.1.6",
"yaml": "^2.8.2",
"yjs": "^13.6.29",
"zustand": "^5.0.3"
},
"devDependencies": {

View File

@ -98,7 +98,19 @@ import SocialFeedPage from '@/pages/volunteer/SocialFeedPage';
import DiscoverPage from '@/pages/volunteer/DiscoverPage';
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
import AchievementsPage from '@/pages/volunteer/AchievementsPage';
import { ADMIN_ROLES } from '@/types/api';
import {
ADMIN_ROLES,
INFLUENCE_ROLES,
BROADCAST_ROLES,
CONTENT_ROLES,
MAP_ROLES,
SCHEDULING_ROLES,
MEDIA_ROLES,
PAYMENTS_ROLES,
EVENTS_ROLES,
SOCIAL_ROLES,
SYSTEM_ROLES,
} from '@/types/api';
import { isAdmin } from '@/utils/roles';
import QuickJoinPage from '@/pages/public/QuickJoinPage';
import VerifyEmailPage from '@/pages/VerifyEmailPage';
@ -128,7 +140,6 @@ import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage';
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
import AdminCalendarPage from '@/pages/AdminCalendarPage';
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
import TicketedEventsPage from '@/pages/events/TicketedEventsPage';
import EventDetailPage from '@/pages/events/EventDetailPage';
@ -381,7 +392,7 @@ export default function App() {
<Route
path="/app/events/:id/checkin"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
<FeatureGate feature="enableTicketedEvents">
<CheckInScannerPage />
</FeatureGate>
@ -422,7 +433,7 @@ export default function App() {
<Route
path="social"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
<FeatureGate feature="enableSocial">
<SocialDashboardPage />
</FeatureGate>
@ -432,7 +443,7 @@ export default function App() {
<Route
path="social/graph"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
<FeatureGate feature="enableSocial">
<SocialGraphPage />
</FeatureGate>
@ -442,7 +453,7 @@ export default function App() {
<Route
path="social/moderation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
<FeatureGate feature="enableSocial">
<SocialModerationPage />
</FeatureGate>
@ -452,7 +463,7 @@ export default function App() {
<Route
path="social/referrals"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
<FeatureGate feature="enableSocial">
<ReferralAdminPage />
</FeatureGate>
@ -462,7 +473,7 @@ export default function App() {
<Route
path="social/spotlights"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
<FeatureGate feature="enableSocial">
<SpotlightAdminPage />
</FeatureGate>
@ -472,7 +483,7 @@ export default function App() {
<Route
path="social/challenges"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
<FeatureGate feature="enableSocial">
<ChallengesAdminPage />
</FeatureGate>
@ -482,7 +493,7 @@ export default function App() {
<Route
path="campaigns"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<CampaignsPage />
</ProtectedRoute>
}
@ -490,7 +501,7 @@ export default function App() {
<Route
path="representatives"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<RepresentativesPage />
</ProtectedRoute>
}
@ -498,7 +509,7 @@ export default function App() {
<Route
path="email-queue"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<EmailQueuePage />
</ProtectedRoute>
}
@ -506,7 +517,7 @@ export default function App() {
<Route
path="email-templates"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
<EmailTemplatesPage />
</ProtectedRoute>
}
@ -514,7 +525,7 @@ export default function App() {
<Route
path="responses"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ResponsesPage />
</ProtectedRoute>
}
@ -522,7 +533,7 @@ export default function App() {
<Route
path="campaign-moderation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<CampaignModerationPage />
</ProtectedRoute>
}
@ -530,7 +541,7 @@ export default function App() {
<Route
path="influence/effectiveness"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<CampaignEffectivenessPage />
</ProtectedRoute>
}
@ -538,7 +549,7 @@ export default function App() {
<Route
path="influence/stories"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
<ImpactStoriesPage />
</ProtectedRoute>
}
@ -546,7 +557,7 @@ export default function App() {
<Route
path="listmonk"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
<ListmonkPage />
</ProtectedRoute>
}
@ -554,7 +565,7 @@ export default function App() {
<Route
path="pages"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<LandingPagesPage />
</ProtectedRoute>
}
@ -562,7 +573,7 @@ export default function App() {
<Route
path="docs"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<DocsPage />
</ProtectedRoute>
}
@ -570,7 +581,7 @@ export default function App() {
<Route
path="docs/settings"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<MkDocsSettingsPage />
</ProtectedRoute>
}
@ -578,7 +589,7 @@ export default function App() {
<Route
path="docs/analytics"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<DocsAnalyticsPage />
</ProtectedRoute>
}
@ -586,7 +597,7 @@ export default function App() {
<Route
path="docs/comments"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<DocsCommentsPage />
</ProtectedRoute>
}
@ -594,7 +605,7 @@ export default function App() {
<Route
path="navigation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<NavigationSettingsPage />
</ProtectedRoute>
}
@ -602,7 +613,7 @@ export default function App() {
<Route
path="code"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
<CodeEditorPage />
</ProtectedRoute>
}
@ -610,7 +621,7 @@ export default function App() {
<Route
path="services/nocodb"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<NocoDBPage />
</ProtectedRoute>
}
@ -618,7 +629,7 @@ export default function App() {
<Route
path="services/n8n"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<N8nPage />
</ProtectedRoute>
}
@ -626,7 +637,7 @@ export default function App() {
<Route
path="services/gitea"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<GiteaPage />
</ProtectedRoute>
}
@ -634,7 +645,7 @@ export default function App() {
<Route
path="services/mailhog"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<MailHogPage />
</ProtectedRoute>
}
@ -642,7 +653,7 @@ export default function App() {
<Route
path="services/miniqr"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<MiniQRPage />
</ProtectedRoute>
}
@ -650,7 +661,7 @@ export default function App() {
<Route
path="services/excalidraw"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<ExcalidrawPage />
</ProtectedRoute>
}
@ -658,7 +669,7 @@ export default function App() {
<Route
path="services/vaultwarden"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<VaultwardenPage />
</ProtectedRoute>
}
@ -674,7 +685,7 @@ export default function App() {
<Route
path="services/gancio"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
<GancioPage />
</ProtectedRoute>
}
@ -682,7 +693,7 @@ export default function App() {
<Route
path="services/jitsi"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
<JitsiMeetPage />
</ProtectedRoute>
}
@ -699,7 +710,7 @@ export default function App() {
<Route
path="sms"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
<SmsDashboardPage />
</ProtectedRoute>
}
@ -707,7 +718,7 @@ export default function App() {
<Route
path="sms/contacts"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
<SmsContactsPage />
</ProtectedRoute>
}
@ -715,7 +726,7 @@ export default function App() {
<Route
path="sms/campaigns"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
<SmsCampaignsPage />
</ProtectedRoute>
}
@ -723,7 +734,7 @@ export default function App() {
<Route
path="sms/conversations"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
<SmsConversationsPage />
</ProtectedRoute>
}
@ -731,7 +742,7 @@ export default function App() {
<Route
path="sms/templates"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
<SmsTemplatesPage />
</ProtectedRoute>
}
@ -739,7 +750,7 @@ export default function App() {
<Route
path="settings"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<SettingsPage />
</ProtectedRoute>
}
@ -747,7 +758,7 @@ export default function App() {
<Route
path="tunnel"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<PangolinPage />
</ProtectedRoute>
}
@ -755,7 +766,7 @@ export default function App() {
<Route
path="observability"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
<ObservabilityPage />
</ProtectedRoute>
}
@ -763,7 +774,7 @@ export default function App() {
<Route
path="map"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MAP_ROLES}>
<LocationsPage />
</ProtectedRoute>
}
@ -771,7 +782,7 @@ export default function App() {
<Route
path="map/data-quality"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MAP_ROLES}>
<DataQualityDashboardPage />
</ProtectedRoute>
}
@ -779,7 +790,7 @@ export default function App() {
<Route
path="map/shifts"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SCHEDULING_ROLES}>
<ShiftsPage />
</ProtectedRoute>
}
@ -787,7 +798,7 @@ export default function App() {
<Route
path="meeting-planner"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
<MeetingPlannerPage />
</ProtectedRoute>
}
@ -795,23 +806,15 @@ export default function App() {
<Route
path="scheduling/calendar-views/:id"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SCHEDULING_ROLES}>
<AdminCalendarViewPage />
</ProtectedRoute>
}
/>
<Route
path="scheduling/calendar-views"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<AdminCalendarPage />
</ProtectedRoute>
}
/>
<Route
path="scheduling/calendar"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={SCHEDULING_ROLES}>
<SchedulingCalendarPage />
</ProtectedRoute>
}
@ -819,7 +822,7 @@ export default function App() {
<Route
path="events"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
<FeatureGate feature="enableTicketedEvents">
<TicketedEventsPage />
</FeatureGate>
@ -829,7 +832,7 @@ export default function App() {
<Route
path="events/:id"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
<FeatureGate feature="enableTicketedEvents">
<EventDetailPage />
</FeatureGate>
@ -839,7 +842,7 @@ export default function App() {
<Route
path="map/cuts"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MAP_ROLES}>
<CutsPage />
</ProtectedRoute>
}
@ -847,7 +850,7 @@ export default function App() {
<Route
path="map/settings"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MAP_ROLES}>
<MapSettingsPage />
</ProtectedRoute>
}
@ -855,7 +858,7 @@ export default function App() {
<Route
path="map/cuts/:id/export"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MAP_ROLES}>
<CutExportPage />
</ProtectedRoute>
}
@ -863,7 +866,7 @@ export default function App() {
<Route
path="map/canvass"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MAP_ROLES}>
<CanvassDashboardPage />
</ProtectedRoute>
}
@ -871,7 +874,7 @@ export default function App() {
<Route
path="media/library"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
<LibraryPage />
</ProtectedRoute>
}
@ -879,7 +882,7 @@ export default function App() {
<Route
path="media/analytics"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
<AnalyticsDashboardPage />
</ProtectedRoute>
}
@ -887,7 +890,7 @@ export default function App() {
<Route
path="media/jobs"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
<MediaJobsPage />
</ProtectedRoute>
}
@ -895,7 +898,7 @@ export default function App() {
<Route
path="media/curated"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
<PlaylistManagementPage />
</ProtectedRoute>
}
@ -903,7 +906,7 @@ export default function App() {
<Route
path="media/moderation"
element={
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
<CommentModerationPage />
</ProtectedRoute>
}
@ -911,7 +914,7 @@ export default function App() {
<Route
path="payments/ads/analytics"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<AdAnalyticsDashboardPage />
</ProtectedRoute>
}
@ -919,7 +922,7 @@ export default function App() {
<Route
path="payments/ads"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<GalleryAdsPage />
</ProtectedRoute>
}
@ -927,7 +930,7 @@ export default function App() {
<Route
path="payments"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<PaymentsDashboardPage />
</ProtectedRoute>
}
@ -935,7 +938,7 @@ export default function App() {
<Route
path="payments/plans"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<PlansPage />
</ProtectedRoute>
}
@ -943,7 +946,7 @@ export default function App() {
<Route
path="payments/subscribers"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<SubscribersPage />
</ProtectedRoute>
}
@ -951,7 +954,7 @@ export default function App() {
<Route
path="payments/products"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<PaymentProductsPage />
</ProtectedRoute>
}
@ -959,7 +962,7 @@ export default function App() {
<Route
path="payments/donations"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<PaymentDonationsPage />
</ProtectedRoute>
}
@ -967,7 +970,7 @@ export default function App() {
<Route
path="payments/donation-pages"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<DonationPagesPage />
</ProtectedRoute>
}
@ -975,7 +978,7 @@ export default function App() {
<Route
path="payments/settings"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
<PaymentSettingsPage />
</ProtectedRoute>
}

View File

@ -60,7 +60,17 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import { useSettingsStore } from '@/stores/settings.store';
import { hasAnyRole } from '@/utils/roles';
import type { PageHeaderConfig, AppOutletContext } from '@/types/api';
import type { PageHeaderConfig, AppOutletContext, User } from '@/types/api';
import {
INFLUENCE_ROLES,
BROADCAST_ROLES,
CONTENT_ROLES,
MAP_ROLES,
SCHEDULING_ROLES,
MEDIA_ROLES,
PAYMENTS_ROLES,
SOCIAL_ROLES,
} from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api';
import {
@ -122,7 +132,10 @@ const ADMIN_ICON_OVERRIDES: Record<string, React.ReactNode> = {
PlayCircleOutlined: <PlaySquareOutlined />,
};
function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number; pendingEmails?: number; pendingCampaignReview?: number; pendingComments?: number }): MenuProps['items'] {
function buildMenuItems(settings: import('@/types/api').SiteSettings | null, user: User | null, badges?: { pendingResponses?: number; pendingEmails?: number; pendingCampaignReview?: number; pendingComments?: number }): MenuProps['items'] {
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const can = (roles: import('@/types/api').UserRole[]) => hasAnyRole(user, roles);
const items: MenuProps['items'] = [
{
key: '/app',
@ -131,14 +144,14 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
},
];
// People & Access submenu — Users always visible, People gated by feature flag
// People & Access submenu — Users visible to any admin, social sub-items gated by SOCIAL_ROLES
{
const communityChildren: MenuProps['items'] = [];
if (settings?.enablePeople) {
communityChildren.push({ key: '/app/people', icon: <ContactsOutlined />, label: 'People' });
}
communityChildren.push({ key: '/app/users', icon: <TeamOutlined />, label: 'Users' });
if (settings?.enableSocial) {
if (settings?.enableSocial && can(SOCIAL_ROLES)) {
communityChildren.push(
{ key: '/app/social', icon: <HeartOutlined />, label: 'Social Dashboard' },
{ key: '/app/social/graph', icon: <ApartmentOutlined />, label: 'Social Graph' },
@ -156,7 +169,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
});
}
if (settings?.enableInfluence !== false) {
if (settings?.enableInfluence !== false && can(INFLUENCE_ROLES)) {
items.push({
key: 'influence-submenu',
icon: <SendOutlined />,
@ -173,7 +186,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
});
}
if (settings?.enableNewsletter !== false) {
if (settings?.enableNewsletter !== false && can(BROADCAST_ROLES)) {
const broadcastChildren: MenuProps['items'] = [
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
@ -204,24 +217,26 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
}
// Web submenu — conditionally include Landing Pages
const webChildren: MenuProps['items'] = [];
if (settings?.enableLandingPages !== false) {
webChildren.push({ key: '/app/pages', icon: <FileTextOutlined />, label: 'Landing Pages' });
if (can(CONTENT_ROLES)) {
const webChildren: MenuProps['items'] = [];
if (settings?.enableLandingPages !== false) {
webChildren.push({ key: '/app/pages', icon: <FileTextOutlined />, label: 'Landing Pages' });
}
webChildren.push({ key: '/app/navigation', icon: <GlobalOutlined />, label: 'Navigation' });
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({
key: 'web-submenu',
icon: <GlobalOutlined />,
label: 'Web',
children: webChildren,
});
}
webChildren.push({ key: '/app/navigation', icon: <GlobalOutlined />, label: 'Navigation' });
webChildren.push({ key: '/app/docs', icon: <BookOutlined />, label: 'Documentation' });
webChildren.push({ key: '/app/docs/analytics', icon: <BarChartOutlined />, label: 'Analytics' });
webChildren.push({ key: '/app/docs/comments', icon: <MessageOutlined />, label: badges?.pendingComments ? <Badge count={badges.pendingComments} size="small" offset={[8, 0]}>Comments</Badge> : 'Comments' });
webChildren.push({ key: '/app/docs/settings', icon: <SettingOutlined />, label: 'Docs Settings' });
webChildren.push({ key: '/app/code', icon: <CodeOutlined />, label: 'Code Editor' });
items.push({
key: 'web-submenu',
icon: <GlobalOutlined />,
label: 'Web',
children: webChildren,
});
if (settings?.enableMap !== false) {
if (settings?.enableMap !== false && can(MAP_ROLES)) {
items.push({
key: 'map-submenu',
icon: <EnvironmentOutlined />,
@ -236,8 +251,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
});
}
// Scheduling submenu — visible if Shifts, Meeting Planner, or Ticketed Events is enabled
if (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents || settings?.enableMeet || settings?.enableEvents) {
// Scheduling submenu — visible if relevant features are enabled AND user has SCHEDULING_ROLES
if (can(SCHEDULING_ROLES) && (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents || settings?.enableMeet || settings?.enableEvents)) {
const schedulingChildren: any[] = [];
if (settings?.enableMap !== false) {
schedulingChildren.push({ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' });
@ -254,7 +269,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
if (settings?.enableEvents) {
schedulingChildren.push({ key: '/app/services/gancio', icon: <GlobalOutlined />, label: 'Gancio' });
}
schedulingChildren.push({ key: '/app/scheduling/calendar-views', icon: <TeamOutlined />, label: 'Calendar Views' });
// Always add Calendar as the last item in scheduling
schedulingChildren.push({ key: '/app/scheduling/calendar', icon: <CalendarOutlined />, label: 'Calendar' });
if (schedulingChildren.length > 0) {
@ -267,7 +281,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
}
}
if (settings?.enableMediaFeatures !== false) {
if (settings?.enableMediaFeatures !== false && can(MEDIA_ROLES)) {
items.push({
key: 'media-submenu',
icon: <PlaySquareOutlined />,
@ -282,7 +296,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
});
}
if (settings?.enablePayments) {
if (settings?.enablePayments && can(PAYMENTS_ROLES)) {
items.push({
key: 'payments-submenu',
icon: <DollarOutlined />,
@ -323,13 +337,15 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
});
}
items.push(
{
key: '/app/settings',
icon: <SettingOutlined />,
label: 'Settings',
},
);
if (isSuperAdmin) {
items.push(
{
key: '/app/settings',
icon: <SettingOutlined />,
label: 'Settings',
},
);
}
return items;
}
@ -345,7 +361,6 @@ export default function AppLayout() {
const { token } = theme.useToken();
const screens = useBreakpoint();
const isMobile = !screens.md;
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const [badgeCounts, setBadgeCounts] = useState<{ pendingResponses: number; pendingEmails: number; pendingCampaignReview: number; pendingComments: number }>({ pendingResponses: 0, pendingEmails: 0, pendingCampaignReview: 0, pendingComments: 0 });
const fetchBadges = useCallback(() => {
@ -365,7 +380,7 @@ export default function AppLayout() {
return () => clearInterval(interval);
}, [fetchBadges]);
const baseMenuItems = buildMenuItems(settings, isSuperAdmin, badgeCounts);
const baseMenuItems = buildMenuItems(settings, user, badgeCounts);
const { favorites } = useFavoritesStore();
// Build final menu: resolve favorites, add stars, prepend favorites section

View File

@ -32,6 +32,7 @@ import {
isItemActive,
} from '@/lib/nav-defaults';
import type { NavItem } from '@/types/api';
import { isAdmin as checkIsAdmin } from '@/utils/roles';
const navItemStyle: React.CSSProperties = {
color: 'rgba(255, 255, 255, 0.85)',
@ -65,7 +66,7 @@ interface PublicNavBarProps {
export default function PublicNavBar({ activePath, showAuth = true, onSignInClick }: PublicNavBarProps) {
const { settings } = useSettingsStore();
const { isAuthenticated, logout, user } = useAuthStore();
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN';
const isAdmin = isAuthenticated && user ? checkIsAdmin(user) : false;
const location = useLocation();
const navigate = useNavigate();
const [drawerOpen, setDrawerOpen] = useState(false);

View File

@ -21,6 +21,7 @@ import VolunteerFooterNav from '@/components/VolunteerFooterNav';
import PublicNavBar from '@/components/PublicNavBar';
import { useSSE } from '@/hooks/useSSE';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { isAdmin as checkIsAdmin } from '@/utils/roles';
const { Content, Footer } = Layout;
@ -35,7 +36,7 @@ export default function VolunteerLayout() {
// Initialize SSE connection for real-time notifications + online presence
useSSE();
const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN';
const isAdmin = user ? checkIsAdmin(user) : false;
const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';

View File

@ -0,0 +1,192 @@
import { Drawer, Button, Space, Tag, Typography, Divider, theme } from 'antd';
import {
ClockCircleOutlined,
EnvironmentOutlined,
CalendarOutlined,
EditOutlined,
DeleteOutlined,
BellOutlined,
LockOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { PersonalCalendarItem } from '@/types/api';
import { hexToRgba, formatTimeShort } from './calendarUtils';
const { Text, Title } = Typography;
interface CalendarItemDetailProps {
item: PersonalCalendarItem | null;
open: boolean;
onClose: () => void;
onEdit: (item: PersonalCalendarItem) => void;
onDelete: (item: PersonalCalendarItem) => void;
}
export default function CalendarItemDetail({
item,
open,
onClose,
onEdit,
onDelete,
}: CalendarItemDetailProps) {
const { token } = theme.useToken();
if (!item) return null;
const isPersonal = item.type === 'personal';
const isTimeBlock = item.itemType === 'TIME_BLOCK';
const isReminder = item.itemType === 'REMINDER';
const color = item.color || token.colorPrimary;
return (
<Drawer
open={open}
onClose={onClose}
width={360}
title={null}
closable={false}
styles={{
body: { padding: '16px 20px', display: 'flex', flexDirection: 'column' },
}}
>
{/* Color bar + title */}
<div style={{ display: 'flex', gap: 12, alignItems: 'flex-start', marginBottom: 16 }}>
<div
style={{
width: 4,
height: 40,
borderRadius: 2,
background: color,
flexShrink: 0,
marginTop: 2,
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Title level={5} style={{ margin: 0 }}>
{isReminder && <BellOutlined style={{ marginRight: 6, fontSize: 14 }} />}
{isTimeBlock && item.showDetailsTo === 'NOBODY' ? (
<>
<LockOutlined style={{ marginRight: 6, fontSize: 14 }} />
Busy
</>
) : (
item.title
)}
</Title>
{item.type !== 'personal' && (
<Tag
color="blue"
style={{ marginTop: 4, fontSize: 11, textTransform: 'capitalize' }}
>
{item.type}
</Tag>
)}
{isTimeBlock && (
<Tag style={{ marginTop: 4, fontSize: 11 }}>Time Block</Tag>
)}
</div>
</div>
<Divider style={{ margin: '12px 0' }} />
{/* Date & time */}
<DetailRow icon={<CalendarOutlined />}>
{dayjs(item.date).format('dddd, MMMM D, YYYY')}
</DetailRow>
<DetailRow icon={<ClockCircleOutlined />}>
{item.isAllDay
? 'All day'
: `${formatTimeShort(item.startTime)} - ${formatTimeShort(item.endTime)}`}
</DetailRow>
{/* Location */}
{item.location && (
<DetailRow icon={<EnvironmentOutlined />}>
{item.location}
</DetailRow>
)}
{/* Busy status */}
{item.busyStatus && item.busyStatus !== 'BUSY' && (
<DetailRow icon={null}>
<Tag style={{ fontSize: 11, textTransform: 'capitalize' }}>
{item.busyStatus.toLowerCase().replace('_', ' ')}
</Tag>
</DetailRow>
)}
{/* Recurrence */}
{item.isRecurring && (
<DetailRow icon={null}>
<Tag color="purple" style={{ fontSize: 11 }}>Recurring</Tag>
</DetailRow>
)}
{/* Layer color indicator */}
<div style={{ marginTop: 16 }}>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 6,
padding: '4px 10px',
borderRadius: 12,
background: hexToRgba(color, 0.12),
border: `1px solid ${hexToRgba(color, 0.25)}`,
fontSize: 12,
}}
>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: color }} />
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.65)' }}>
{item.type === 'personal' ? 'Personal' : item.type}
</Text>
</div>
</div>
{/* Bottom actions: Close on left, Edit/Delete on right */}
<div style={{ marginTop: 'auto', paddingTop: 24, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Button onClick={onClose}>
Close
</Button>
{isPersonal && (
<Space>
<Button
danger
icon={<DeleteOutlined />}
onClick={() => {
onClose();
onDelete(item);
}}
>
Delete
</Button>
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => {
onClose();
onEdit(item);
}}
>
Edit
</Button>
</Space>
)}
</div>
</Drawer>
);
}
function DetailRow({ icon, children }: { icon: React.ReactNode; children: React.ReactNode }) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 10 }}>
{icon && (
<span style={{ fontSize: 14, color: 'rgba(255,255,255,0.45)', width: 16, textAlign: 'center' }}>
{icon}
</span>
)}
<Text style={{ fontSize: 14 }}>{children}</Text>
</div>
);
}

View File

@ -0,0 +1,525 @@
import { useMemo, useRef, useEffect, useCallback } from 'react';
import { Typography, theme } from 'antd';
import {
BellOutlined,
EnvironmentOutlined,
LockOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { PersonalCalendarItem } from '@/types/api';
import {
hexToRgba,
assignLanes,
formatHourLabel,
getViewDates,
formatTimeShort,
START_HOUR,
END_HOUR,
SLOT_HEIGHT,
} from './calendarUtils';
const { Text } = Typography;
interface CalendarTimeGridProps {
items: PersonalCalendarItem[];
viewMode: 'day' | '3day' | 'week';
currentDate: dayjs.Dayjs;
onDateSelect: (date: string) => void;
onItemClick: (item: PersonalCalendarItem) => void;
onNavigate: (direction: 'prev' | 'next') => void;
}
const GUTTER_WIDTH = 44;
const totalHeight = (END_HOUR - START_HOUR) * SLOT_HEIGHT;
const SWIPE_THRESHOLD = 50;
export default function CalendarTimeGrid({
items,
viewMode,
currentDate,
onDateSelect,
onItemClick,
onNavigate,
}: CalendarTimeGridProps) {
const { token } = theme.useToken();
const scrollRef = useRef<HTMLDivElement>(null);
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
const dates = useMemo(() => getViewDates(viewMode, currentDate), [viewMode, currentDate]);
const todayKey = dayjs().format('YYYY-MM-DD');
// Scroll to current time on mount
useEffect(() => {
if (scrollRef.current) {
const now = dayjs();
const minutes = now.hour() * 60 + now.minute();
const scrollTo = ((minutes - START_HOUR * 60) / 60) * SLOT_HEIGHT - 100;
scrollRef.current.scrollTop = Math.max(0, scrollTo);
}
}, []);
// Group items by date
const itemsByDate = useMemo(() => {
const map: Record<string, PersonalCalendarItem[]> = {};
for (const item of items) {
if (!map[item.date]) map[item.date] = [];
map[item.date]!.push(item);
}
return map;
}, [items]);
// Touch swipe handling
const handleTouchStart = useCallback((e: React.TouchEvent) => {
const touch = e.touches[0];
if (touch) {
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
}
}, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (!touchStartRef.current) return;
const touch = e.changedTouches[0];
if (!touch) return;
const dx = touch.clientX - touchStartRef.current.x;
const dy = touch.clientY - touchStartRef.current.y;
touchStartRef.current = null;
// Only trigger swipe if horizontal movement is dominant
if (Math.abs(dx) > SWIPE_THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.5) {
onNavigate(dx < 0 ? 'next' : 'prev');
}
}, [onNavigate]);
// Current time indicator
const nowIndicatorTop = useMemo(() => {
const now = dayjs();
const minutes = now.hour() * 60 + now.minute();
const top = ((minutes - START_HOUR * 60) / 60) * SLOT_HEIGHT;
if (top < 0 || top > totalHeight) return null;
return top;
}, []);
const columnCount = dates.length;
const isNarrow = viewMode === 'week';
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 180px)',
background: 'rgba(255,255,255,0.02)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
overflow: 'hidden',
}}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
{/* Column headers */}
<div
style={{
display: 'flex',
borderBottom: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.03)',
flexShrink: 0,
}}
>
{/* Gutter spacer */}
<div style={{ width: GUTTER_WIDTH, flexShrink: 0 }} />
{dates.map((date) => {
const dateKey = date.format('YYYY-MM-DD');
const isToday = dateKey === todayKey;
return (
<div
key={dateKey}
onClick={() => onDateSelect(dateKey)}
style={{
flex: 1,
textAlign: 'center',
padding: '8px 2px',
cursor: 'pointer',
borderLeft: '1px solid rgba(255,255,255,0.06)',
}}
>
<Text
style={{
fontSize: isNarrow ? 11 : 12,
color: 'rgba(255,255,255,0.45)',
display: 'block',
textTransform: 'uppercase',
letterSpacing: 0.5,
}}
>
{isNarrow ? date.format('dd') : date.format('ddd')}
</Text>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: isNarrow ? 24 : 28,
height: isNarrow ? 24 : 28,
borderRadius: '50%',
background: isToday ? token.colorPrimary : 'transparent',
color: isToday ? '#fff' : 'rgba(255,255,255,0.85)',
fontSize: isNarrow ? 13 : 15,
fontWeight: isToday ? 700 : 500,
marginTop: 2,
}}
>
{date.format('D')}
</div>
</div>
);
})}
</div>
{/* All-day section */}
<AllDaySection
dates={dates}
itemsByDate={itemsByDate}
onItemClick={onItemClick}
isNarrow={isNarrow}
/>
{/* Scrollable time grid */}
<div
ref={scrollRef}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
position: 'relative',
}}
>
<div style={{ position: 'relative', height: totalHeight }}>
{/* Hour lines */}
{Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => {
const hour = START_HOUR + i;
const top = i * SLOT_HEIGHT;
return (
<div
key={hour}
style={{
position: 'absolute',
top,
left: 0,
right: 0,
display: 'flex',
alignItems: 'flex-start',
}}
>
<Text
style={{
width: GUTTER_WIDTH,
fontSize: 11,
color: 'rgba(255,255,255,0.3)',
textAlign: 'right',
paddingRight: 8,
marginTop: -7,
flexShrink: 0,
}}
>
{formatHourLabel(hour)}
</Text>
<div
style={{
flex: 1,
borderTop: '1px solid rgba(255,255,255,0.06)',
height: 0,
}}
/>
</div>
);
})}
{/* Column dividers */}
{dates.map((date, i) => {
if (i === 0) return null;
return (
<div
key={`div-${date.format('YYYY-MM-DD')}`}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `calc(${GUTTER_WIDTH}px + ${(i / columnCount) * 100}% * (1 - ${GUTTER_WIDTH}px / 100%))`,
width: 1,
background: 'rgba(255,255,255,0.06)',
}}
/>
);
})}
{/* Column click zones (for creating events on empty space) */}
{dates.map((date, i) => {
const dateKey = date.format('YYYY-MM-DD');
return (
<div
key={`zone-${dateKey}`}
onClick={() => onDateSelect(dateKey)}
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: `calc(${GUTTER_WIDTH}px + ${(i / columnCount)} * (100% - ${GUTTER_WIDTH}px))`,
width: `calc((100% - ${GUTTER_WIDTH}px) / ${columnCount})`,
cursor: 'pointer',
}}
/>
);
})}
{/* Current time indicator */}
{nowIndicatorTop !== null && dates.some((d) => d.format('YYYY-MM-DD') === todayKey) && (
<div
style={{
position: 'absolute',
top: nowIndicatorTop,
left: GUTTER_WIDTH,
right: 0,
height: 2,
background: token.colorError,
zIndex: 5,
borderRadius: 1,
pointerEvents: 'none',
}}
>
<div
style={{
position: 'absolute',
left: -4,
top: -3,
width: 8,
height: 8,
borderRadius: '50%',
background: token.colorError,
}}
/>
</div>
)}
{/* Events per column */}
{dates.map((date, colIndex) => {
const dateKey = date.format('YYYY-MM-DD');
const dayItems = (itemsByDate[dateKey] ?? []).filter((it) => !it.isAllDay);
const lanedItems = assignLanes(dayItems);
return lanedItems.map(({ item, top, height, lane, totalLanes }) => {
const colWidth = `calc((100% - ${GUTTER_WIDTH}px) / ${columnCount})`;
const colLeft = `calc(${GUTTER_WIDTH}px + ${colIndex} * (100% - ${GUTTER_WIDTH}px) / ${columnCount})`;
const laneWidth = totalLanes > 1 ? `calc(${100 / totalLanes}% - 2px)` : 'calc(100% - 6px)';
const laneOffset = totalLanes > 1 ? `calc(${(lane / totalLanes) * 100}% + 1px)` : '3px';
return (
<EventBlock
key={item.id}
item={item}
top={top}
height={height}
colLeft={colLeft}
colWidth={colWidth}
laneWidth={laneWidth}
laneOffset={laneOffset}
isNarrow={isNarrow}
onClick={() => onItemClick(item)}
/>
);
});
})}
</div>
</div>
</div>
);
}
/** All-day events section spanning across columns */
function AllDaySection({
dates,
itemsByDate,
onItemClick,
isNarrow,
}: {
dates: dayjs.Dayjs[];
itemsByDate: Record<string, PersonalCalendarItem[]>;
onItemClick: (item: PersonalCalendarItem) => void;
isNarrow: boolean;
}) {
const hasAllDay = dates.some((d) => {
const items = itemsByDate[d.format('YYYY-MM-DD')];
return items?.some((it) => it.isAllDay);
});
if (!hasAllDay) return null;
return (
<div
style={{
display: 'flex',
borderBottom: '1px solid rgba(255,255,255,0.08)',
flexShrink: 0,
minHeight: 28,
}}
>
<div
style={{
width: GUTTER_WIDTH,
flexShrink: 0,
fontSize: 10,
color: 'rgba(255,255,255,0.3)',
textAlign: 'right',
paddingRight: 8,
paddingTop: 4,
}}
>
all-day
</div>
{dates.map((date) => {
const dateKey = date.format('YYYY-MM-DD');
const allDay = (itemsByDate[dateKey] ?? []).filter((it) => it.isAllDay);
return (
<div
key={dateKey}
style={{
flex: 1,
borderLeft: '1px solid rgba(255,255,255,0.06)',
padding: '2px 2px',
display: 'flex',
flexDirection: 'column',
gap: 2,
}}
>
{allDay.map((item) => (
<div
key={item.id}
onClick={() => onItemClick(item)}
style={{
background: hexToRgba(item.color, 0.2),
borderLeft: `3px solid ${item.color}`,
borderRadius: 4,
padding: '2px 4px',
fontSize: isNarrow ? 10 : 11,
color: 'rgba(255,255,255,0.85)',
cursor: 'pointer',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
}}
>
{item.title}
</div>
))}
</div>
);
})}
</div>
);
}
/** Individual event block positioned within a column */
function EventBlock({
item,
top,
height,
colLeft,
colWidth,
laneWidth,
laneOffset,
isNarrow,
onClick,
}: {
item: PersonalCalendarItem;
top: number;
height: number;
colLeft: string;
colWidth: string;
laneWidth: string;
laneOffset: string;
isNarrow: boolean;
onClick: () => void;
}) {
const { token } = theme.useToken();
const isTimeBlock = item.itemType === 'TIME_BLOCK';
const isReminder = item.itemType === 'REMINDER';
const color = item.color || token.colorPrimary;
const isBusyHidden = isTimeBlock && item.showDetailsTo === 'NOBODY';
return (
<div
onClick={(e) => {
e.stopPropagation();
onClick();
}}
style={{
position: 'absolute',
top: top + 1,
left: `calc(${colLeft} + ${laneOffset})`,
width: `calc(min(${colWidth}, ${laneWidth}))`,
height: height - 2,
background: isTimeBlock ? hexToRgba(color, 0.1) : hexToRgba(color, 0.2),
border: isTimeBlock
? `1px dashed ${hexToRgba(color, 0.35)}`
: `1px solid ${hexToRgba(color, 0.4)}`,
borderLeft: `3px solid ${color}`,
borderRadius: 6,
padding: isNarrow ? '2px 3px' : '4px 8px',
cursor: 'pointer',
overflow: 'hidden',
zIndex: 2,
transition: 'background 0.15s',
}}
>
{isBusyHidden ? (
<Text style={{ fontSize: isNarrow ? 10 : 12, color: 'rgba(255,255,255,0.45)' }}>
<LockOutlined style={{ marginRight: 4, fontSize: 10 }} />
{!isNarrow && 'Busy'}
</Text>
) : (
<>
<Text
strong
style={{
fontSize: isNarrow ? 10 : 12,
color: 'rgba(255,255,255,0.85)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{isReminder && <BellOutlined style={{ marginRight: 3, fontSize: 10 }} />}
{item.title}
</Text>
{!isNarrow && (
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>
{formatTimeShort(item.startTime)} - {formatTimeShort(item.endTime)}
</Text>
)}
{isNarrow && height > 30 && (
<Text style={{ fontSize: 9, color: 'rgba(255,255,255,0.45)', display: 'block' }}>
{formatTimeShort(item.startTime)}
</Text>
)}
{item.location && height > 45 && !isNarrow && (
<Text
style={{
fontSize: 10,
color: 'rgba(255,255,255,0.4)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<EnvironmentOutlined style={{ marginRight: 3 }} />
{item.location}
</Text>
)}
</>
)}
</div>
);
}

View File

@ -1,424 +0,0 @@
import { useMemo } from 'react';
import { Button, Typography, Empty, theme } from 'antd';
import {
LeftOutlined,
RightOutlined,
PlusOutlined,
BellOutlined,
EnvironmentOutlined,
LockOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs';
import type { PersonalCalendarItem, CalendarLayer } from '@/types/api';
const { Text } = Typography;
const { useToken } = theme;
interface MobileDayViewProps {
items: PersonalCalendarItem[];
layers?: CalendarLayer[];
currentMonth: Dayjs;
selectedDate: string | null;
onDateSelect: (date: string) => void;
onMonthChange: (month: Dayjs) => void;
onItemClick: (item: PersonalCalendarItem) => void;
onAddItem: (date: string) => void;
}
// Time slots from 6am to 10pm
const START_HOUR = 6;
const END_HOUR = 22;
const SLOT_HEIGHT = 60; // px per hour
export default function MobileDayView({
items,
selectedDate,
onDateSelect,
onItemClick,
onAddItem,
}: MobileDayViewProps) {
const { token } = useToken();
const currentDate = selectedDate ? dayjs(selectedDate) : dayjs();
const dateKey = currentDate.format('YYYY-MM-DD');
const isToday = dateKey === dayjs().format('YYYY-MM-DD');
// Filter items for the selected date
const dayItems = useMemo(() => {
return items
.filter((item) => item.date === dateKey)
.sort((a, b) => a.startTime.localeCompare(b.startTime));
}, [items, dateKey]);
const allDayItems = dayItems.filter((item) => item.isAllDay);
const timedItems = dayItems.filter((item) => !item.isAllDay);
// Calculate position and height of timed items
const positionedItems = useMemo(() => {
return timedItems.map((item) => {
const parts = item.startTime.split(':');
const sh = parseInt(parts[0] ?? '0', 10);
const sm = parseInt(parts[1] ?? '0', 10);
const endParts = item.endTime.split(':');
const eh = parseInt(endParts[0] ?? '0', 10);
const em = parseInt(endParts[1] ?? '0', 10);
const startMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
const topMinutes = startMinutes - START_HOUR * 60;
const durationMinutes = Math.max(endMinutes - startMinutes, 15); // min 15min display
return {
item,
top: Math.max(0, (topMinutes / 60) * SLOT_HEIGHT),
height: Math.max(20, (durationMinutes / 60) * SLOT_HEIGHT),
};
});
}, [timedItems]);
const totalHeight = (END_HOUR - START_HOUR) * SLOT_HEIGHT;
// Current time indicator position
const nowIndicatorTop = useMemo(() => {
if (!isToday) return null;
const now = dayjs();
const minutes = now.hour() * 60 + now.minute();
const top = ((minutes - START_HOUR * 60) / 60) * SLOT_HEIGHT;
if (top < 0 || top > totalHeight) return null;
return top;
}, [isToday, totalHeight]);
const handlePrev = () => {
onDateSelect(currentDate.subtract(1, 'day').format('YYYY-MM-DD'));
};
const handleNext = () => {
onDateSelect(currentDate.add(1, 'day').format('YYYY-MM-DD'));
};
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'rgba(255,255,255,0.02)',
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.08)',
overflow: 'hidden',
position: 'relative',
}}
>
{/* Date header with navigation */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 16px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
background: 'rgba(255,255,255,0.03)',
}}
>
<Button
type="text"
icon={<LeftOutlined />}
onClick={handlePrev}
style={{ color: 'rgba(255,255,255,0.65)' }}
/>
<div style={{ textAlign: 'center' }}>
<Text
strong
style={{
color: isToday ? token.colorPrimary : 'rgba(255,255,255,0.85)',
fontSize: 16,
display: 'block',
}}
>
{currentDate.format('dddd')}
</Text>
<Text style={{ color: 'rgba(255,255,255,0.55)', fontSize: 13 }}>
{currentDate.format('MMMM D, YYYY')}
</Text>
</div>
<Button
type="text"
icon={<RightOutlined />}
onClick={handleNext}
style={{ color: 'rgba(255,255,255,0.65)' }}
/>
</div>
{/* Today button */}
{!isToday && (
<div style={{ textAlign: 'center', padding: '6px 0' }}>
<Button
type="link"
size="small"
onClick={() => onDateSelect(dayjs().format('YYYY-MM-DD'))}
style={{ fontSize: 12 }}
>
Go to today
</Button>
</div>
)}
{/* All-day items section */}
{allDayItems.length > 0 && (
<div
style={{
padding: '8px 16px',
borderBottom: '1px solid rgba(255,255,255,0.08)',
display: 'flex',
flexDirection: 'column',
gap: 4,
}}
>
<Text type="secondary" style={{ fontSize: 11, textTransform: 'uppercase', letterSpacing: 0.5 }}>
All Day
</Text>
{allDayItems.map((item) => (
<ItemPill
key={item.id}
item={item}
onClick={() => onItemClick(item)}
/>
))}
</div>
)}
{/* Scrollable time grid */}
<div
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
position: 'relative',
}}
>
{dayItems.length === 0 && (
<div style={{ padding: '60px 20px', textAlign: 'center' }}>
<Empty
description="Nothing scheduled"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</div>
)}
{(timedItems.length > 0 || allDayItems.length > 0) && (
<div style={{ position: 'relative', height: totalHeight, margin: '0 16px' }}>
{/* Hour lines */}
{Array.from({ length: END_HOUR - START_HOUR + 1 }, (_, i) => {
const hour = START_HOUR + i;
const top = i * SLOT_HEIGHT;
return (
<div
key={hour}
style={{
position: 'absolute',
top,
left: 0,
right: 0,
display: 'flex',
alignItems: 'flex-start',
}}
>
<Text
style={{
width: 44,
fontSize: 11,
color: 'rgba(255,255,255,0.3)',
textAlign: 'right',
paddingRight: 8,
marginTop: -7,
flexShrink: 0,
}}
>
{hour === 0 ? '12 AM' : hour < 12 ? `${hour} AM` : hour === 12 ? '12 PM' : `${hour - 12} PM`}
</Text>
<div
style={{
flex: 1,
borderTop: '1px solid rgba(255,255,255,0.06)',
height: 0,
}}
/>
</div>
);
})}
{/* Current time indicator */}
{nowIndicatorTop !== null && (
<div
style={{
position: 'absolute',
top: nowIndicatorTop,
left: 44,
right: 0,
height: 2,
background: token.colorError,
zIndex: 5,
borderRadius: 1,
}}
>
<div
style={{
position: 'absolute',
left: -4,
top: -3,
width: 8,
height: 8,
borderRadius: '50%',
background: token.colorError,
}}
/>
</div>
)}
{/* Positioned items */}
{positionedItems.map(({ item, top, height }) => {
const isTimeBlock = item.itemType === 'TIME_BLOCK';
const isReminder = item.itemType === 'REMINDER';
const color = item.color || token.colorPrimary;
const isBusyHidden = isTimeBlock && item.showDetailsTo === 'NOBODY';
return (
<div
key={item.id}
onClick={() => onItemClick(item)}
style={{
position: 'absolute',
top: top + 1,
left: 50,
right: 4,
height: height - 2,
background: isTimeBlock
? hexToRgba(color, 0.1)
: hexToRgba(color, 0.2),
border: isTimeBlock
? `1px dashed ${hexToRgba(color, 0.35)}`
: `1px solid ${hexToRgba(color, 0.4)}`,
borderLeft: `3px solid ${color}`,
borderRadius: 6,
padding: '4px 8px',
cursor: 'pointer',
overflow: 'hidden',
zIndex: 2,
transition: 'background 0.15s',
}}
>
{isBusyHidden ? (
<Text style={{ fontSize: 12, color: 'rgba(255,255,255,0.45)' }}>
<LockOutlined style={{ marginRight: 4, fontSize: 10 }} />
Busy
</Text>
) : (
<>
<Text
strong
style={{
fontSize: 12,
color: 'rgba(255,255,255,0.85)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{isReminder && <BellOutlined style={{ marginRight: 4, fontSize: 10 }} />}
{item.title}
</Text>
<Text style={{ fontSize: 11, color: 'rgba(255,255,255,0.5)' }}>
{item.startTime} - {item.endTime}
</Text>
{item.location && height > 45 && (
<Text
style={{
fontSize: 10,
color: 'rgba(255,255,255,0.4)',
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
<EnvironmentOutlined style={{ marginRight: 3 }} />
{item.location}
</Text>
)}
</>
)}
</div>
);
})}
</div>
)}
</div>
{/* Floating add button */}
<Button
type="primary"
shape="circle"
icon={<PlusOutlined />}
size="large"
onClick={() => onAddItem(dateKey)}
style={{
position: 'absolute',
bottom: 20,
right: 20,
width: 48,
height: 48,
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
zIndex: 10,
}}
/>
</div>
);
}
/** Compact item pill for all-day items */
function ItemPill({
item,
onClick,
}: {
item: PersonalCalendarItem;
onClick: () => void;
}) {
const isTimeBlock = item.itemType === 'TIME_BLOCK';
const isReminder = item.itemType === 'REMINDER';
const color = item.color;
return (
<div
onClick={onClick}
style={{
background: hexToRgba(color, isTimeBlock ? 0.1 : 0.2),
border: isTimeBlock
? `1px dashed ${hexToRgba(color, 0.35)}`
: `1px solid ${hexToRgba(color, 0.4)}`,
borderLeft: `3px solid ${color}`,
borderRadius: 6,
padding: '6px 10px',
cursor: 'pointer',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
fontSize: 13,
color: 'rgba(255,255,255,0.85)',
}}
>
{isReminder && <BellOutlined style={{ marginRight: 4, fontSize: 11 }} />}
{item.title}
</div>
);
}
/** Convert a hex color to rgba string */
function hexToRgba(hex: string, alpha: number): string {
const cleaned = hex.replace('#', '');
if (cleaned.length !== 6) return `rgba(24, 144, 255, ${alpha})`;
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@ -1,8 +1,9 @@
import { useMemo } from 'react';
import { Calendar, Spin, Empty, theme } from 'antd';
import { BellOutlined } from '@ant-design/icons';
import { BellOutlined, EnvironmentOutlined } from '@ant-design/icons';
import type { Dayjs } from 'dayjs';
import type { PersonalCalendarItem, CalendarLayer } from '@/types/api';
import { hexToRgba, formatTimeShort } from './calendarUtils';
const { useToken } = theme;
@ -22,6 +23,7 @@ const MAX_CELL_ITEMS = 3;
export default function PersonalCalendarView({
items,
loading,
currentMonth,
onDateSelect,
onItemClick,
onMonthChange,
@ -39,7 +41,6 @@ export default function PersonalCalendarView({
map[item.date] = [item];
}
}
// Sort items within each date by startTime
for (const key of Object.keys(map)) {
map[key]!.sort((a, b) => a.startTime.localeCompare(b.startTime));
}
@ -77,6 +78,7 @@ export default function PersonalCalendarView({
const color = item.color || '#1890ff';
const bgAlpha = isTimeBlock ? 0.1 : 0.2;
const borderAlpha = isTimeBlock ? 0.3 : 0.5;
const isSystemType = item.type !== 'personal';
return (
<div
@ -93,8 +95,8 @@ export default function PersonalCalendarView({
borderLeft: `3px solid ${color}`,
borderRadius: 4,
padding: '2px 5px',
fontSize: 11,
lineHeight: '15px',
fontSize: 12,
lineHeight: '16px',
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
@ -105,13 +107,31 @@ export default function PersonalCalendarView({
>
{!item.isAllDay && (
<span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}>
{item.startTime}
{formatTimeShort(item.startTime)}-{formatTimeShort(item.endTime)}
</span>
)}
{isReminder && (
<BellOutlined style={{ fontSize: 9, marginRight: 3, color: 'rgba(255,255,255,0.5)' }} />
)}
{isSystemType && (
<span
style={{
fontSize: 9,
color: 'rgba(255,255,255,0.5)',
marginRight: 3,
textTransform: 'uppercase',
}}
>
[{item.type}]
</span>
)}
{item.title}
{item.location && (
<span style={{ color: 'rgba(255,255,255,0.4)', marginLeft: 4, fontSize: 10 }}>
<EnvironmentOutlined style={{ fontSize: 9, marginRight: 2 }} />
{item.location}
</span>
)}
</div>
);
})}
@ -141,9 +161,11 @@ export default function PersonalCalendarView({
)}
<Calendar
fullscreen
value={currentMonth}
cellRender={(date) => cellRender(date)}
onSelect={handleSelect}
onPanelChange={handlePanelChange}
headerRender={() => null}
/>
{items.length === 0 && (
<div
@ -161,13 +183,3 @@ export default function PersonalCalendarView({
</div>
);
}
/** Convert a hex color to rgba string */
function hexToRgba(hex: string, alpha: number): string {
const cleaned = hex.replace('#', '');
if (cleaned.length !== 6) return `rgba(24, 144, 255, ${alpha})`;
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}

View File

@ -0,0 +1,172 @@
import type { Dayjs } from 'dayjs';
import type { PersonalCalendarItem } from '@/types/api';
export type CalendarViewMode = 'day' | '3day' | 'week' | 'month';
export const START_HOUR = 6;
export const END_HOUR = 22;
export const SLOT_HEIGHT = 60; // px per hour
/** Convert a hex color to rgba string */
export function hexToRgba(hex: string, alpha: number): string {
const cleaned = hex.replace('#', '');
if (cleaned.length !== 6) return `rgba(24, 144, 255, ${alpha})`;
const r = parseInt(cleaned.slice(0, 2), 16);
const g = parseInt(cleaned.slice(2, 4), 16);
const b = parseInt(cleaned.slice(4, 6), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
/** Calculate pixel position and height for a timed calendar item */
export function positionTimedItem(item: PersonalCalendarItem): { top: number; height: number } {
const parts = item.startTime.split(':');
const sh = parseInt(parts[0] ?? '0', 10);
const sm = parseInt(parts[1] ?? '0', 10);
const endParts = item.endTime.split(':');
const eh = parseInt(endParts[0] ?? '0', 10);
const em = parseInt(endParts[1] ?? '0', 10);
const startMinutes = sh * 60 + sm;
const endMinutes = eh * 60 + em;
const topMinutes = startMinutes - START_HOUR * 60;
const durationMinutes = Math.max(endMinutes - startMinutes, 15);
return {
top: Math.max(0, (topMinutes / 60) * SLOT_HEIGHT),
height: Math.max(20, (durationMinutes / 60) * SLOT_HEIGHT),
};
}
export interface LanedItem {
item: PersonalCalendarItem;
top: number;
height: number;
lane: number;
totalLanes: number;
}
/** Assign lanes for overlapping timed items so they render side-by-side */
export function assignLanes(items: PersonalCalendarItem[]): LanedItem[] {
if (items.length === 0) return [];
const positioned = items
.map((item) => ({ item, ...positionTimedItem(item) }))
.sort((a, b) => a.top - b.top || a.height - b.height);
// Greedy lane assignment: for each item, find the first lane where it doesn't overlap
const lanes: { end: number }[] = [];
const result: LanedItem[] = [];
for (const p of positioned) {
const itemEnd = p.top + p.height;
let assignedLane = -1;
for (let i = 0; i < lanes.length; i++) {
if (lanes[i]!.end <= p.top) {
assignedLane = i;
lanes[i]!.end = itemEnd;
break;
}
}
if (assignedLane === -1) {
assignedLane = lanes.length;
lanes.push({ end: itemEnd });
}
result.push({ ...p, lane: assignedLane, totalLanes: 0 });
}
// Second pass: determine totalLanes for each overlap group
// Group overlapping items and set totalLanes for the group
const groups: number[][] = [];
for (let i = 0; i < result.length; i++) {
const r = result[i]!;
let added = false;
for (const group of groups) {
const overlaps = group.some((gi) => {
const g = result[gi]!;
return r.top < g.top + g.height && r.top + r.height > g.top;
});
if (overlaps) {
group.push(i);
added = true;
break;
}
}
if (!added) {
groups.push([i]);
}
}
for (const group of groups) {
const maxLane = Math.max(...group.map((i) => result[i]!.lane)) + 1;
for (const i of group) {
result[i]!.totalLanes = maxLane;
}
}
return result;
}
/** Get the date range needed for API fetching based on view mode */
export function getDateRangeForView(
viewMode: CalendarViewMode,
anchorDate: Dayjs,
): { startDate: string; endDate: string } {
switch (viewMode) {
case 'day':
return {
startDate: anchorDate.format('YYYY-MM-DD'),
endDate: anchorDate.format('YYYY-MM-DD'),
};
case '3day':
return {
startDate: anchorDate.format('YYYY-MM-DD'),
endDate: anchorDate.add(2, 'day').format('YYYY-MM-DD'),
};
case 'week':
return {
startDate: anchorDate.startOf('week').format('YYYY-MM-DD'),
endDate: anchorDate.endOf('week').format('YYYY-MM-DD'),
};
case 'month':
return {
startDate: anchorDate.startOf('month').subtract(7, 'day').format('YYYY-MM-DD'),
endDate: anchorDate.endOf('month').add(7, 'day').format('YYYY-MM-DD'),
};
}
}
/** Format an hour number to a display label like "6 AM", "12 PM" */
export function formatHourLabel(hour: number): string {
if (hour === 0) return '12 AM';
if (hour < 12) return `${hour} AM`;
if (hour === 12) return '12 PM';
return `${hour - 12} PM`;
}
/** Get the number of columns and dates for a view mode */
export function getViewDates(viewMode: CalendarViewMode, anchorDate: Dayjs): Dayjs[] {
switch (viewMode) {
case 'day':
return [anchorDate];
case '3day':
return [anchorDate, anchorDate.add(1, 'day'), anchorDate.add(2, 'day')];
case 'week': {
const start = anchorDate.startOf('week');
return Array.from({ length: 7 }, (_, i) => start.add(i, 'day'));
}
default:
return [anchorDate];
}
}
/** Format a time string like "09:30" to "9:30 AM" */
export function formatTimeShort(time: string): string {
const [h, m] = time.split(':');
const hour = parseInt(h ?? '0', 10);
const min = m ?? '00';
const suffix = hour < 12 ? 'AM' : 'PM';
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
return min === '00' ? `${h12} ${suffix}` : `${h12}:${min} ${suffix}`;
}

View File

@ -64,7 +64,7 @@ export const commandRegistry: CommandItem[] = [
keywords: ['social', 'community', 'friends', 'connections', 'engagement'],
category: 'navigation',
featureFlag: 'enableSocial',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
requiredRoles: ['SUPER_ADMIN', 'SOCIAL_ADMIN'],
},
{
id: 'nav-social-graph',
@ -76,7 +76,7 @@ export const commandRegistry: CommandItem[] = [
keywords: ['network', 'connections', 'graph', 'relationships', 'visualization'],
category: 'navigation',
featureFlag: 'enableSocial',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
requiredRoles: ['SUPER_ADMIN', 'SOCIAL_ADMIN'],
},
{
id: 'nav-social-moderation',
@ -88,7 +88,7 @@ export const commandRegistry: CommandItem[] = [
keywords: ['moderation', 'reports', 'flagged', 'content review', 'social moderation'],
category: 'navigation',
featureFlag: 'enableSocial',
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
requiredRoles: ['SUPER_ADMIN', 'SOCIAL_ADMIN'],
},
// ── Navigation: Advocacy ──────────────────────────────
@ -170,7 +170,7 @@ export const commandRegistry: CommandItem[] = [
keywords: ['listmonk', 'mailing list', 'subscribers', 'broadcast', 'email marketing'],
category: 'navigation',
featureFlag: 'enableNewsletter',
requiredRoles: ['SUPER_ADMIN'],
requiredRoles: ['SUPER_ADMIN', 'BROADCAST_ADMIN'],
},
{
id: 'nav-email-templates',

View File

@ -81,7 +81,7 @@ const entityConfigs: EntitySearchConfig[] = [
subtitleField: 'slug',
pathPrefix: '/app/payments/products',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
requiredRoles: ['SUPER_ADMIN', 'PAYMENTS_ADMIN'],
extractItems: (data: unknown) => (data as { products?: unknown[] })?.products ?? [],
},
{
@ -91,7 +91,7 @@ const entityConfigs: EntitySearchConfig[] = [
subtitleField: 'slug',
pathPrefix: '/app/payments/donation-pages',
featureFlag: 'enablePayments',
requiredRoles: ['SUPER_ADMIN'],
requiredRoles: ['SUPER_ADMIN', 'PAYMENTS_ADMIN'],
extractItems: (data: unknown) => (data as { donationPages?: unknown[] })?.donationPages ?? [],
},
{

View File

@ -0,0 +1,66 @@
import { Tooltip, Badge, theme } from 'antd';
import { WifiOutlined, DisconnectOutlined } from '@ant-design/icons';
import type { Collaborator } from '@/hooks/useDocsCollaboration';
interface CollaboratorAvatarsProps {
collaborators: Collaborator[];
connected: boolean;
}
function getInitials(name: string): string {
const parts = name.trim().split(/\s+/);
if (parts.length >= 2) {
return ((parts[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')).toUpperCase();
}
return name.slice(0, 2).toUpperCase();
}
export function CollaboratorAvatars({ collaborators, connected }: CollaboratorAvatarsProps) {
const { token } = theme.useToken();
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{/* Connection status indicator */}
<Tooltip title={connected ? 'Connected — real-time sync active' : 'Disconnected — reconnecting...'}>
{connected ? (
<WifiOutlined style={{ fontSize: 12, color: token.colorSuccess }} />
) : (
<DisconnectOutlined style={{ fontSize: 12, color: token.colorError }} />
)}
</Tooltip>
{/* Collaborator avatars */}
{collaborators.map((c) => (
<Tooltip key={c.clientId} title={c.name}>
<div
style={{
width: 22,
height: 22,
borderRadius: '50%',
background: c.color,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 10,
fontWeight: 600,
color: '#fff',
border: `2px solid ${token.colorBgContainer}`,
cursor: 'default',
}}
>
{getInitials(c.name)}
</div>
</Tooltip>
))}
{/* Count badge when there are collaborators */}
{collaborators.length > 0 && (
<Badge
count={collaborators.length}
size="small"
style={{ backgroundColor: token.colorPrimary }}
/>
)}
</div>
);
}

View File

@ -51,11 +51,15 @@ import type { ProductInsertResult } from '@/components/payments/ProductInsertMod
import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
import { YTextareaBinding } from '@/lib/y-textarea';
type MobileTab = 'files' | 'editor' | 'preview';
interface MobileDocsEditorProps {
editor: UseDocsEditorReturn;
collabEnabled?: boolean;
}
// Flatten file tree into a searchable list of file paths
@ -105,7 +109,49 @@ function LineNumberedEditor({
token: ReturnType<typeof theme.useToken>['token'];
}) {
const gutterRef = useRef<HTMLDivElement>(null);
const lineCount = useMemo(() => value.split('\n').length, [value]);
const mirrorRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const [lineHeights, setLineHeights] = useState<number[]>([]);
const lines = useMemo(() => value.split('\n'), [value]);
// Measure actual rendered height of each line using a hidden mirror div
const measureLines = useCallback(() => {
const mirror = mirrorRef.current;
const wrapper = wrapperRef.current;
if (!mirror || !wrapper) return;
// Mirror width must match textarea content area (wrapper - gutter - textarea padding)
const contentWidth = wrapper.clientWidth - 36 - 12;
if (contentWidth <= 0) return;
mirror.style.width = `${contentWidth}px`;
// Render all lines as child divs, measure in a single reflow
const fragment = document.createDocumentFragment();
for (const line of lines) {
const div = document.createElement('div');
div.textContent = line || '\u00a0';
fragment.appendChild(div);
}
mirror.textContent = '';
mirror.appendChild(fragment);
const heights: number[] = [];
const children = mirror.children;
for (let i = 0; i < children.length; i++) {
heights.push((children[i] as HTMLElement).offsetHeight);
}
setLineHeights(heights);
}, [lines]);
// Remeasure on content change and container resize
useEffect(() => {
measureLines();
const wrapper = wrapperRef.current;
if (!wrapper) return;
const observer = new ResizeObserver(measureLines);
observer.observe(wrapper);
return () => observer.disconnect();
}, [measureLines]);
// Sync gutter scroll with textarea scroll
const handleScroll = useCallback(() => {
@ -123,7 +169,22 @@ function LineNumberedEditor({
}, [textareaRef, handleScroll]);
return (
<div style={{ flex: 1, display: 'flex', minHeight: 0, overflow: 'hidden' }}>
<div ref={wrapperRef} style={{ flex: 1, display: 'flex', minHeight: 0, overflow: 'hidden', position: 'relative' }}>
{/* Hidden mirror div — same font/wrapping as textarea, used to measure line heights */}
<div
ref={mirrorRef}
aria-hidden="true"
style={{
position: 'absolute',
visibility: 'hidden',
height: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
fontFamily: MONO_FONT,
fontSize: FONT_SIZE,
lineHeight: `${LINE_HEIGHT}px`,
}}
/>
{/* Line number gutter */}
<div
ref={gutterRef}
@ -143,8 +204,8 @@ function LineNumberedEditor({
paddingRight: 6,
}}
>
{Array.from({ length: lineCount }, (_, i) => (
<div key={i + 1} style={{ height: LINE_HEIGHT }}>{i + 1}</div>
{(lineHeights.length > 0 ? lineHeights : lines.map(() => LINE_HEIGHT)).map((h, i) => (
<div key={i + 1} style={{ height: h }}>{i + 1}</div>
))}
</div>
{/* Textarea */}
@ -169,7 +230,9 @@ function LineNumberedEditor({
color: token.colorText,
boxSizing: 'border-box',
overflow: 'auto',
whiteSpace: 'pre',
whiteSpace: 'pre-wrap',
wordBreak: 'break-all',
overflowX: 'hidden',
WebkitTextSizeAdjust: 'none',
}}
/>
@ -177,12 +240,13 @@ function LineNumberedEditor({
);
}
export function MobileDocsEditor({ editor }: MobileDocsEditorProps) {
export function MobileDocsEditor({ editor, collabEnabled = false }: MobileDocsEditorProps) {
const { token } = theme.useToken();
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
const [activeTab, setActiveTab] = useState<MobileTab>('files');
const [searchOpen, setSearchOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const yBindingRef = useRef<YTextareaBinding | null>(null);
// Insert modal state
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
@ -231,6 +295,30 @@ export function MobileDocsEditor({ editor }: MobileDocsEditorProps) {
contextHolder,
} = editor;
// --- Collaboration ---
const collab = useDocsCollaboration(
selectedFile?.endsWith('.md') ? selectedFile : null,
collabEnabled,
);
// Bind Y.Text to textarea when both are ready
useEffect(() => {
if (yBindingRef.current) {
yBindingRef.current.destroy();
yBindingRef.current = null;
}
const ta = textareaRef.current;
if (!collab.active || !collab.yText || !ta) return;
const binding = new YTextareaBinding(collab.yText, ta);
yBindingRef.current = binding;
return () => {
binding.destroy();
yBindingRef.current = null;
};
}, [collab.active, collab.yText, selectedFile]);
const treeData = useMemo(() => fileNodeToTreeData(filteredTree), [filteredTree]);
// Flat file list for search results
@ -702,7 +790,10 @@ export function MobileDocsEditor({ editor }: MobileDocsEditorProps) {
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 11, flex: 1, color: token.colorTextSecondary }} ellipsis>
{selectedFile}
</Typography.Text>
{dirty && (
{collab.active && (
<CollaboratorAvatars collaborators={collab.collaborators} connected={collab.connected} />
)}
{dirty && !collab.active && (
<span style={{ width: 6, height: 6, borderRadius: '50%', background: token.colorWarning, flexShrink: 0 }} />
)}
</div>

View File

@ -10,6 +10,12 @@ const roleColors: Record<UserRole, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'volcano',
MAP_ADMIN: 'orange',
BROADCAST_ADMIN: 'gold',
CONTENT_ADMIN: 'lime',
MEDIA_ADMIN: 'purple',
PAYMENTS_ADMIN: 'green',
EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta',
USER: 'blue',
TEMP: 'default',
};

View File

@ -86,6 +86,8 @@ interface PollData {
finalizedOptionId: string | null;
finalizedOption: PollOption | null;
allowAnonymous: boolean;
isPrivate?: boolean;
requiresAuth?: boolean;
createdBy?: { name: string | null; email: string };
options: PollOption[];
voters: PollVoter[];
@ -248,6 +250,43 @@ export function SchedulingPollWidget({ pollSlug, showComments = true, title }: S
);
}
if (poll.requiresAuth) {
return (
<div style={{ maxWidth: 700, margin: '0 auto', fontFamily: "'Inter', -apple-system, sans-serif", color: COLORS.text }}>
{title && (
<h2 style={{ textAlign: 'center', marginBottom: 8, fontSize: '1.5rem', fontWeight: 700 }}>
{title}
</h2>
)}
<div style={{
background: COLORS.card,
borderRadius: 8,
border: `1px solid ${COLORS.border}`,
padding: 32,
textAlign: 'center',
}}>
<div style={{ fontSize: 40, marginBottom: 12 }}>🔒</div>
<h3 style={{ margin: '0 0 8px', fontSize: '1.25rem' }}>{poll.title}</h3>
<p style={{ color: COLORS.textMuted, margin: '0 0 20px', lineHeight: 1.5 }}>
This poll is private. Please sign in to view the details and participate.
</p>
<a
href={`/login?redirect=/poll/${pollSlug}`}
style={{
...btnStyle,
display: 'inline-block',
width: 'auto',
padding: '10px 32px',
textDecoration: 'none',
}}
>
Sign In to View
</a>
</div>
</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;

View File

@ -0,0 +1,156 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import * as Y from 'yjs';
import { HocuspocusProvider } from '@hocuspocus/provider';
import { useAuthStore } from '@/stores/auth.store';
// Deterministic color from user ID (must match server palette)
const COLLAB_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F0B27A', '#82E0AA', '#F1948A', '#AED6F1', '#D7BDE2',
];
function getUserColor(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
}
return COLLAB_COLORS[Math.abs(hash) % COLLAB_COLORS.length] ?? '#85C1E9';
}
export interface Collaborator {
id: string;
name: string;
color: string;
clientId: number;
}
export interface UseDocsCollaborationReturn {
yDoc: Y.Doc | null;
yText: Y.Text | null;
provider: HocuspocusProvider | null;
connected: boolean;
synced: boolean;
collaborators: Collaborator[];
/** Whether collaboration is active and synced (ready for binding) */
active: boolean;
}
/**
* Manages Y.Doc lifecycle, WebSocket connection, and awareness for collaborative editing.
* Creates a new Y.Doc + HocuspocusProvider when filePath changes.
* `active` is only true after the initial Y.js sync completes (content is available).
*/
export function useDocsCollaboration(
filePath: string | null,
enabled: boolean,
): UseDocsCollaborationReturn {
const [connected, setConnected] = useState(false);
const [synced, setSynced] = useState(false);
const [collaborators, setCollaborators] = useState<Collaborator[]>([]);
// Use state (not refs) so changes trigger re-renders for dependent effects
const [collabState, setCollabState] = useState<{
yDoc: Y.Doc;
yText: Y.Text;
provider: HocuspocusProvider;
} | null>(null);
const user = useAuthStore((s) => s.user);
const accessToken = useAuthStore((s) => s.accessToken);
// Build WebSocket URL
const wsUrl = useMemo(() => {
if (!filePath || !enabled || !accessToken) return null;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/api/docs/collaborate`;
}, [filePath, enabled, accessToken]);
// Cleanup function
const cleanup = useCallback(() => {
setCollabState((prev) => {
if (prev) {
prev.provider.disconnect();
prev.provider.destroy();
prev.yDoc.destroy();
}
return null;
});
setConnected(false);
setSynced(false);
setCollaborators([]);
}, []);
useEffect(() => {
if (!wsUrl || !filePath || !enabled || !accessToken || !user) {
cleanup();
return;
}
// Create new Y.Doc and provider
const doc = new Y.Doc();
const yText = doc.getText('content');
const provider = new HocuspocusProvider({
url: wsUrl,
name: filePath,
document: doc,
token: accessToken,
onConnect: () => setConnected(true),
onDisconnect: () => {
setConnected(false);
setSynced(false);
},
onSynced: () => setSynced(true),
});
// Set local awareness state
provider.setAwarenessField('user', {
id: user.id,
name: user.name || user.email.split('@')[0],
color: getUserColor(user.id),
});
// Track collaborators via awareness changes
const handleAwarenessChange = () => {
const states = provider.awareness?.getStates();
if (!states) return;
const collab: Collaborator[] = [];
states.forEach((state, clientId) => {
const u = state.user as { id: string; name: string; color: string } | undefined;
if (u && u.id !== user.id) {
collab.push({ id: u.id, name: u.name, color: u.color, clientId });
}
});
setCollaborators(collab);
};
provider.awareness?.on('change', handleAwarenessChange);
// Set state LAST so the component re-renders with everything ready
setCollabState({ yDoc: doc, yText, provider });
return () => {
provider.awareness?.off('change', handleAwarenessChange);
provider.disconnect();
provider.destroy();
doc.destroy();
setCollabState(null);
setConnected(false);
setSynced(false);
setCollaborators([]);
};
// We intentionally only recreate when these core values change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [wsUrl, filePath, enabled, accessToken, user?.id]);
return {
yDoc: collabState?.yDoc ?? null,
yText: collabState?.yText ?? null,
provider: collabState?.provider ?? null,
connected,
synced,
collaborators,
// Only active after provider is connected AND initial sync is complete
active: enabled && !!collabState && synced && !!filePath,
};
}

View File

@ -68,8 +68,6 @@ export const DEFAULT_NAV_ITEMS: NavItem[] = [
{ id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 0, type: 'builtin', featureFlag: 'enableMap' },
{ id: 'events', label: 'Calendar', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableEvents' },
{ id: 'polls', label: 'Polls', path: '/polls', icon: 'BarChartOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMeetingPlanner' },
{ id: 'tickets', label: 'Tickets', path: '/events/tickets', icon: 'TagOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableTicketedEvents' },
{ id: 'meet', label: 'Meet', path: '/meet', icon: 'VideoCameraOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMeet' },
],
},
{ id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableMediaFeatures' },

View File

@ -0,0 +1,90 @@
import * as Y from 'yjs';
/**
* Lightweight Y.Text textarea binding.
* Observes Y.Text changes updates textarea value.
* Listens to textarea input events applies delta to Y.Text.
* Preserves cursor position across remote updates.
*/
export class YTextareaBinding {
private yText: Y.Text;
private textarea: HTMLTextAreaElement;
private observer: (event: Y.YTextEvent) => void;
private inputHandler: (e: Event) => void;
private destroyed = false;
constructor(yText: Y.Text, textarea: HTMLTextAreaElement) {
this.yText = yText;
this.textarea = textarea;
// Initialize textarea with current Y.Text content
textarea.value = yText.toString();
// Observe Y.Text changes (remote updates) → update textarea
this.observer = () => {
if (this.destroyed) return;
const newVal = yText.toString();
if (textarea.value === newVal) return;
// Save cursor position and adjust for changes
const cursorBefore = textarea.selectionStart;
const lengthBefore = textarea.value.length;
textarea.value = newVal;
const lengthAfter = newVal.length;
// Adjust cursor: shift by the length difference if cursor was after the change
const delta = lengthAfter - lengthBefore;
const newCursor = Math.max(0, Math.min(cursorBefore + delta, lengthAfter));
textarea.selectionStart = newCursor;
textarea.selectionEnd = newCursor;
};
yText.observe(this.observer);
// Listen to textarea input events → apply delta to Y.Text
this.inputHandler = () => {
if (this.destroyed) return;
const newVal = textarea.value;
const oldVal = yText.toString();
if (newVal === oldVal) return;
// Find the changed region
// Find common prefix length
let prefixLen = 0;
const minLen = Math.min(oldVal.length, newVal.length);
while (prefixLen < minLen && oldVal[prefixLen] === newVal[prefixLen]) {
prefixLen++;
}
// Find common suffix length (don't overlap with prefix)
let suffixLen = 0;
while (
suffixLen < (oldVal.length - prefixLen) &&
suffixLen < (newVal.length - prefixLen) &&
oldVal[oldVal.length - 1 - suffixLen] === newVal[newVal.length - 1 - suffixLen]
) {
suffixLen++;
}
const deleteCount = oldVal.length - prefixLen - suffixLen;
const insertText = newVal.slice(prefixLen, newVal.length - suffixLen);
// Apply changes to Y.Text in a transaction
yText.doc?.transact(() => {
if (deleteCount > 0) {
yText.delete(prefixLen, deleteCount);
}
if (insertText.length > 0) {
yText.insert(prefixLen, insertText);
}
});
};
textarea.addEventListener('input', this.inputHandler);
}
destroy(): void {
this.destroyed = true;
this.yText.unobserve(this.observer);
this.textarea.removeEventListener('input', this.inputHandler);
}
}

View File

@ -77,7 +77,7 @@ export default function AdminCalendarViewPage() {
setTotalUsers(itemsRes.data.totalUsers);
setTruncated(itemsRes.data.truncated);
} catch {
navigate('/app/scheduling/calendar-views');
navigate('/app/scheduling/calendar');
} finally {
setLoading(false);
}
@ -221,7 +221,7 @@ export default function AdminCalendarViewPage() {
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/scheduling/calendar-views')}
onClick={() => navigate('/app/scheduling/calendar')}
/>
<Title level={5} style={{ margin: 0 }}>{view.name}</Title>
</Space>
@ -264,7 +264,7 @@ export default function AdminCalendarViewPage() {
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/app/scheduling/calendar-views')}
onClick={() => navigate('/app/scheduling/calendar')}
/>
<CalendarOutlined style={{ fontSize: 18 }} />
<Title level={4} style={{ margin: 0 }}>{view.name}</Title>

View File

@ -85,6 +85,10 @@ import type { ProductInsertResult } from '@/components/payments/ProductInsertMod
import { AdPickerModal } from '@/components/media/AdPickerModal';
import type { AdInsertResult } from '@/components/media/AdPickerModal';
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
import { useDocsCollaboration } from '@/hooks/useDocsCollaboration';
import { CollaboratorAvatars } from '@/components/docs/CollaboratorAvatars';
import { MonacoBinding } from 'y-monaco';
import type { SiteSettings } from '@/types/api';
type LayoutMode = 'split' | 'editor' | 'preview';
type PreviewMode = 'desktop' | 'mobile';
@ -551,7 +555,13 @@ function applySnippet(
/** Wrapper component so useDocsEditor() hook only runs on mobile */
function MobileDocsEditorWrapper() {
const editor = useDocsEditor();
return <MobileDocsEditor editor={editor} />;
const [collabEnabled, setCollabEnabled] = useState(false);
useEffect(() => {
api.get<SiteSettings>('/settings')
.then(({ data }) => setCollabEnabled(!!data.enableDocsCollaboration))
.catch(() => {});
}, []);
return <MobileDocsEditor editor={editor} collabEnabled={collabEnabled} />;
}
export default function DocsPage() {
@ -612,6 +622,20 @@ export default function DocsPage() {
const previewIframeRef = useRef<HTMLIFrameElement>(null);
const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
const monacoRef = useRef<typeof import('monaco-editor') | null>(null);
const monacoBindingRef = useRef<MonacoBinding | null>(null);
// --- Collaboration state ---
const [collabEnabled, setCollabEnabled] = useState(false);
useEffect(() => {
api.get<SiteSettings>('/settings')
.then(({ data }) => setCollabEnabled(!!data.enableDocsCollaboration))
.catch(() => {});
}, []);
const collab = useDocsCollaboration(
selectedFile?.endsWith('.md') ? selectedFile : null,
collabEnabled,
);
const [messageApi, contextHolder] = message.useMessage();
@ -756,12 +780,17 @@ export default function DocsPage() {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
saveFile();
if (collab.active) {
// In collab mode, auto-save handles persistence — just refresh preview
previewIframeRef.current?.contentWindow?.location.reload();
} else {
saveFile();
}
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [saveFile]);
}, [saveFile, collab.active]);
const onEditorChange = useCallback((value: string | undefined) => {
const v = value ?? '';
@ -799,6 +828,53 @@ export default function DocsPage() {
}
}, []);
// --- MonacoBinding effect: binds Y.Text to Monaco editor when both are ready ---
useEffect(() => {
// Clean up any existing binding
if (monacoBindingRef.current) {
monacoBindingRef.current.destroy();
monacoBindingRef.current = null;
}
const ed = monacoEditorRef.current;
if (!collab.active || !collab.yText || !ed || !collab.provider) return;
const model = ed.getModel();
if (!model) return;
// Create the MonacoBinding — this syncs Y.Text ↔ Monaco model
// Remote cursors/selections are rendered automatically
const binding = new MonacoBinding(
collab.yText,
model,
new Set([ed]),
collab.provider.awareness,
);
monacoBindingRef.current = binding;
return () => {
binding.destroy();
monacoBindingRef.current = null;
};
}, [collab.active, collab.yText, collab.provider, selectedFile]);
// Auto-refresh preview when remote changes arrive in collab mode
useEffect(() => {
if (!collab.active || !collab.yText) return;
let refreshTimer: ReturnType<typeof setTimeout>;
const observer = () => {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
previewIframeRef.current?.contentWindow?.location.reload();
}, 2000);
};
collab.yText.observe(observer);
return () => {
collab.yText?.unobserve(observer);
clearTimeout(refreshTimer);
};
}, [collab.active, collab.yText]);
const handleToolbarSnippet = useCallback((snippetId: string) => {
if (snippetId === 'video-card') {
setVideoPickerOpen(true);
@ -1443,8 +1519,12 @@ export default function DocsPage() {
// Inject header
useEffect(() => {
if (!isMobile && !loading && !fetchError) {
setPageHeader({ title: 'Documentation', actions: headerActions, fullBleed: true });
if (!loading && !fetchError) {
if (isMobile) {
setPageHeader({ fullBleed: true });
} else {
setPageHeader({ title: 'Documentation', actions: headerActions, fullBleed: true });
}
} else {
setPageHeader(null);
}
@ -1934,7 +2014,11 @@ export default function DocsPage() {
{selectedFile ? (
<>
<span style={{ fontFamily: 'monospace' }}>{selectedFile}</span>
{dirty && <span style={{ color: token.colorWarning, fontWeight: 600 }}>Modified</span>}
{collab.active && (
<CollaboratorAvatars collaborators={collab.collaborators} connected={collab.connected} />
)}
{dirty && !collab.active && <span style={{ color: token.colorWarning, fontWeight: 600 }}>Modified</span>}
{collab.active && <span style={{ color: token.colorSuccess, fontSize: 11 }}>Auto-saving</span>}
</>
) : (
<span>Select a file from the tree</span>
@ -2061,8 +2145,7 @@ export default function DocsPage() {
<Editor
language={selectedFile.endsWith('.md') ? 'markdown' : selectedFile.endsWith('.yml') || selectedFile.endsWith('.yaml') ? 'yaml' : selectedFile.endsWith('.json') ? 'json' : selectedFile.endsWith('.css') ? 'css' : selectedFile.endsWith('.html') ? 'html' : selectedFile.endsWith('.js') ? 'javascript' : 'plaintext'}
theme="vs-dark"
value={fileContent}
onChange={onEditorChange}
{...(collab.active ? {} : { value: fileContent, onChange: onEditorChange })}
onMount={handleEditorMount}
options={{
minimap: { enabled: false },

View File

@ -149,6 +149,7 @@ export default function MeetingPlannerPage() {
location: values.location,
timezone: values.timezone,
allowAnonymous: values.allowAnonymous ?? true,
isPrivate: values.isPrivate ?? false,
notifyOnVote: values.notifyOnVote ?? true,
votingDeadline: values.votingDeadline?.toISOString(),
options,
@ -254,6 +255,7 @@ export default function MeetingPlannerPage() {
location: data.location || '',
timezone: data.timezone,
allowAnonymous: data.allowAnonymous,
isPrivate: data.isPrivate,
notifyOnVote: data.notifyOnVote,
votingDeadline: data.votingDeadline ? dayjs(data.votingDeadline) : null,
});
@ -273,6 +275,7 @@ export default function MeetingPlannerPage() {
location: values.location || null,
timezone: values.timezone,
allowAnonymous: values.allowAnonymous,
isPrivate: values.isPrivate,
notifyOnVote: values.notifyOnVote,
votingDeadline: values.votingDeadline?.toISOString() || null,
});
@ -607,6 +610,7 @@ export default function MeetingPlannerPage() {
initialValues={{
timezone: 'America/Edmonton',
allowAnonymous: true,
isPrivate: false,
notifyOnVote: true,
options: [
{ date: null, startTime: null, endTime: null },
@ -669,12 +673,17 @@ export default function MeetingPlannerPage() {
<Divider>Settings</Divider>
<Row gutter={16}>
<Col span={12}>
<Col span={8}>
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Col span={8}>
<Form.Item name="isPrivate" label="Private Poll" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="notifyOnVote" label="Notify on Vote" valuePropName="checked">
<Switch />
</Form.Item>
@ -857,12 +866,17 @@ export default function MeetingPlannerPage() {
<Divider>Settings</Divider>
<Row gutter={16}>
<Col span={12}>
<Col span={8}>
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={12}>
<Col span={8}>
<Form.Item name="isPrivate" label="Private Poll" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="notifyOnVote" label="Notify on Vote" valuePropName="checked">
<Switch />
</Form.Item>

View File

@ -1,31 +1,209 @@
import { useRef } from 'react';
import { Typography, Space } from 'antd';
import { CalendarOutlined } from '@ant-design/icons';
import { useRef, useState, useEffect, useCallback } from 'react';
import {
Typography,
Space,
Button,
Table,
Drawer,
Form,
Input,
Select,
Tag,
Popconfirm,
message,
} from 'antd';
import {
CalendarOutlined,
TeamOutlined,
PlusOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import UnifiedCalendar from '@/components/calendar/UnifiedCalendar';
import type { UnifiedCalendarItem } from '@/types/api';
import { api } from '@/lib/api';
import type { UnifiedCalendarItem, AdminCalendarView } from '@/types/api';
import { useNavigate } from 'react-router-dom';
const { Title, Text } = Typography;
const VIEWS_PANEL_WIDTH = 480;
const FORM_PANEL_WIDTH = 380;
const ROLE_OPTIONS = [
{ label: 'Super Admin', value: 'SUPER_ADMIN' },
{ label: 'Influence Admin', value: 'INFLUENCE_ADMIN' },
{ label: 'Map Admin', value: 'MAP_ADMIN' },
{ label: 'User', value: 'USER' },
{ label: 'Temp', value: 'TEMP' },
];
const LAYER_TYPE_OPTIONS = [
{ label: 'Shifts', value: 'SHIFTS' },
{ label: 'Tickets', value: 'TICKETS' },
{ label: 'Polls', value: 'POLLS' },
{ label: 'Public Events', value: 'PUBLIC_EVENTS' },
];
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'blue',
MAP_ADMIN: 'green',
USER: 'default',
TEMP: 'orange',
};
export default function SchedulingCalendarPage() {
const navigate = useNavigate();
const addEventRef = useRef<(() => void) | null>(null);
// Panel state
const [viewsOpen, setViewsOpen] = useState(false);
const [formOpen, setFormOpen] = useState(false);
// Calendar Views state
const [views, setViews] = useState<AdminCalendarView[]>([]);
const [viewsLoading, setViewsLoading] = useState(false);
const [editingView, setEditingView] = useState<AdminCalendarView | null>(null);
const [saving, setSaving] = useState(false);
const [form] = Form.useForm();
const handleShiftClick = (item: UnifiedCalendarItem) => {
if (item.shiftId) {
navigate('/app/map/shifts');
}
};
const fetchViews = useCallback(async () => {
setViewsLoading(true);
try {
const { data } = await api.get<{ views: AdminCalendarView[] }>('/admin/calendar/shared');
setViews(data.views);
} catch {
message.error('Failed to load calendar views');
} finally {
setViewsLoading(false);
}
}, []);
useEffect(() => {
if (viewsOpen) fetchViews();
}, [viewsOpen, fetchViews]);
const openCreate = () => {
setEditingView(null);
form.resetFields();
setFormOpen(true);
};
const openEdit = (view: AdminCalendarView) => {
setEditingView(view);
form.setFieldsValue({
name: view.name,
description: view.description,
autoIncludeRoles: view.autoIncludeRoles,
includedLayerTypes: view.includedLayerTypes,
});
setFormOpen(true);
};
const handleSave = async () => {
try {
const values = await form.validateFields();
setSaving(true);
if (editingView) {
await api.patch(`/admin/calendar/shared/${editingView.id}`, values);
message.success('View updated');
} else {
await api.post('/admin/calendar/shared', values);
message.success('View created');
}
setFormOpen(false);
fetchViews();
} catch {
// validation or API error
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/admin/calendar/shared/${id}`);
message.success('View deleted');
fetchViews();
} catch {
message.error('Failed to delete view');
}
};
const closeViews = () => {
setFormOpen(false);
setViewsOpen(false);
};
const viewColumns = [
{ title: 'Name', dataIndex: 'name', key: 'name' },
{
title: 'Roles',
dataIndex: 'autoIncludeRoles',
key: 'roles',
render: (roles: string[]) => (
<Space size={4} wrap>
{roles.map((r) => <Tag key={r} color={ROLE_COLORS[r] || 'default'}>{r}</Tag>)}
</Space>
),
},
{
title: 'Layers',
dataIndex: 'includedLayerTypes',
key: 'layerTypes',
render: (types: string[]) => (
<Space size={4} wrap>
{types.map((t) => <Tag key={t}>{t}</Tag>)}
</Space>
),
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 100,
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
},
{
title: '',
key: 'actions',
width: 80,
render: (_: unknown, record: AdminCalendarView) => (
<Space size={4}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={(e) => { e.stopPropagation(); openEdit(record); }} />
<Popconfirm title="Delete this view?" onConfirm={() => handleDelete(record.id)} onCancel={(e) => e?.stopPropagation()}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={(e) => e.stopPropagation()} />
</Popconfirm>
</Space>
),
},
];
// Compute right margin to squish calendar when drawers are open
const drawerOffset = (viewsOpen ? VIEWS_PANEL_WIDTH : 0) + (formOpen ? FORM_PANEL_WIDTH : 0);
const DRAWER_ROOT = { position: 'absolute' as const, top: 64, height: 'calc(100vh - 64px)' };
return (
<div>
<div
style={{
transition: 'margin-right 0.3s ease',
marginRight: drawerOffset,
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<Title level={3} style={{ margin: 0 }}>
<CalendarOutlined style={{ marginRight: 8 }} />
Scheduling Calendar
</Title>
{/* Legend */}
<Space size={12} wrap>
<Space size={4}>
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#1890ff' }} />
@ -43,6 +221,14 @@ export default function SchedulingCalendarPage() {
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#52c41a' }} />
<Text type="secondary" style={{ fontSize: 13 }}>Community Events</Text>
</Space>
<Button
icon={<TeamOutlined />}
type={viewsOpen ? 'primary' : 'default'}
onClick={() => viewsOpen ? closeViews() : setViewsOpen(true)}
>
Shared Views
</Button>
</Space>
</div>
@ -50,6 +236,72 @@ export default function SchedulingCalendarPage() {
onShiftSignup={handleShiftClick}
onAddEvent={addEventRef}
/>
{/* Shared Views list drawer — shifts left when form drawer opens */}
<Drawer
title="Shared Calendar Views"
open={viewsOpen}
onClose={closeViews}
mask={false}
width={VIEWS_PANEL_WIDTH}
rootStyle={DRAWER_ROOT}
destroyOnHidden
styles={{
wrapper: {
transition: 'transform 0.3s ease, width 0.3s ease',
transform: formOpen ? `translateX(-${FORM_PANEL_WIDTH}px)` : undefined,
},
}}
extra={
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={openCreate}>
Create View
</Button>
}
>
<Table
dataSource={views}
columns={viewColumns}
rowKey="id"
loading={viewsLoading}
pagination={false}
size="small"
onRow={(record) => ({
onClick: () => navigate(`/app/scheduling/calendar-views/${record.id}`),
style: { cursor: 'pointer' },
})}
/>
</Drawer>
{/* Create/Edit form drawer */}
<Drawer
title={editingView ? 'Edit Calendar View' : 'Create Calendar View'}
open={formOpen}
onClose={() => setFormOpen(false)}
mask={false}
width={FORM_PANEL_WIDTH}
rootStyle={DRAWER_ROOT}
destroyOnHidden
extra={
<Button type="primary" size="small" onClick={handleSave} loading={saving}>
{editingView ? 'Update' : 'Create'}
</Button>
}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="Name" rules={[{ required: true, message: 'Name is required' }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="autoIncludeRoles" label="Roles" initialValue={[]}>
<Select mode="multiple" options={ROLE_OPTIONS} placeholder="Select roles" />
</Form.Item>
<Form.Item name="includedLayerTypes" label="Layer Types" initialValue={[]}>
<Select mode="multiple" options={LAYER_TYPE_OPTIONS} placeholder="Select layer types" />
</Form.Item>
</Form>
</Drawer>
</div>
);
}

View File

@ -25,6 +25,7 @@ import {
Modal,
Checkbox,
Timeline,
Select,
message,
Spin,
} from 'antd';
@ -53,11 +54,12 @@ import {
ExclamationCircleOutlined,
LoadingOutlined,
ReloadOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { SmtpTestResult, SmtpSendTestResult, UpgradeStatusResponse, UpgradeStatus, UpgradeProgress, UpgradeResult } from '@/types/api';
import type { SmtpTestResult, SmtpSendTestResult, UpgradeStatusResponse, UpgradeStatus, UpgradeProgress, UpgradeResult, UpgradeHistoryResponse } from '@/types/api';
const { Text, Paragraph } = Typography;
@ -489,7 +491,10 @@ export default function SettingsPage() {
<Form.Item label="Gallery Ads" name="enableGalleryAds" valuePropName="checked" extra="Promotional cards inserted into the public video gallery" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Events (Gancio)" name="enableEvents" valuePropName="checked" extra="Event calendar integration (requires gancio container)" style={{ marginBottom: 0 }}>
<Form.Item label="Events (Gancio)" name="enableEvents" valuePropName="checked" extra="Event calendar integration (requires gancio container)" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Docs Collaboration" name="enableDocsCollaboration" valuePropName="checked" extra="Real-time collaborative editing in the documentation editor" style={{ marginBottom: 0 }}>
<Switch />
</Form.Item>
</Card>
@ -697,6 +702,7 @@ const UPGRADE_PHASES = [
];
function SystemUpgradeTab() {
const { settings, updateSettings } = useSettingsStore();
const [status, setStatus] = useState<UpgradeStatus | null>(null);
const [progress, setProgress] = useState<UpgradeProgress | null>(null);
const [result, setResult] = useState<UpgradeResult | null>(null);
@ -707,6 +713,7 @@ function SystemUpgradeTab() {
const [confirmOpen, setConfirmOpen] = useState(false);
const [skipBackup, setSkipBackup] = useState(false);
const [pullServices, setPullServices] = useState(false);
const [history, setHistory] = useState<UpgradeResult[]>([]);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const checkStartRef = useRef<string | null>(null);
@ -725,6 +732,15 @@ function SystemUpgradeTab() {
}
}, []);
const fetchHistory = useCallback(async () => {
try {
const { data } = await api.get<UpgradeHistoryResponse>('/upgrade/history');
setHistory(data.history || []);
} catch {
// Ignore — history is non-critical
}
}, []);
// Initial fetch on mount
useEffect(() => {
fetchStatus().then((data) => {
@ -734,6 +750,7 @@ function SystemUpgradeTab() {
startUpgradePoll();
}
});
fetchHistory();
return () => stopPoll();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@ -758,12 +775,10 @@ function SystemUpgradeTab() {
message.success('Update check complete');
}
}, 2000);
// Auto-stop after 30s
// Auto-stop after 30s (idempotent — harmless if check already completed)
setTimeout(() => {
if (checking) {
setChecking(false);
stopPoll();
}
setChecking(false);
stopPoll();
}, 30000);
};
@ -777,6 +792,7 @@ function SystemUpgradeTab() {
if (data.result && !data.running) {
setUpgrading(false);
stopPoll();
fetchHistory(); // Refresh history after upgrade
if (data.result.success) {
message.success('Upgrade completed successfully');
} else {
@ -819,6 +835,14 @@ function SystemUpgradeTab() {
}
};
const handleAutoUpgradeToggle = async (field: string, value: unknown) => {
try {
await updateSettings({ [field]: value });
} catch {
message.error('Failed to save auto-upgrade settings');
}
};
const formatDate = (dateStr: string) => {
try {
return new Date(dateStr).toLocaleString();
@ -845,6 +869,7 @@ function SystemUpgradeTab() {
};
const isUpgrading = upgrading || running;
const autoUpgradeEnabled = settings?.enableAutoUpgrade ?? false;
return (
<div style={{ maxWidth: 800 }}>
@ -1050,6 +1075,135 @@ function SystemUpgradeTab() {
</Card>
)}
{/* Auto-Upgrade Configuration */}
<Card
size="small"
title={<Space><ClockCircleOutlined /> Auto-Upgrade</Space>}
style={{ marginBottom: 16 }}
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Switch
checked={autoUpgradeEnabled}
onChange={(v) => handleAutoUpgradeToggle('enableAutoUpgrade', v)}
/>
<Text>Enable automatic upgrades</Text>
</div>
{autoUpgradeEnabled && (
<>
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>Schedule</Text>
<Select
value={settings?.autoUpgradeSchedule || 'daily-3am'}
onChange={(v) => handleAutoUpgradeToggle('autoUpgradeSchedule', v)}
style={{ width: 220 }}
options={[
{ label: 'Daily at 3:00 AM', value: 'daily-3am' },
{ label: 'Daily at 4:00 AM', value: 'daily-4am' },
{ label: 'Daily at 5:00 AM', value: 'daily-5am' },
{ label: 'Weekly (Sunday 3 AM)', value: 'weekly-sun-3am' },
{ label: 'Weekly (Monday 3 AM)', value: 'weekly-mon-3am' },
{ label: 'Every 12 hours', value: '12h' },
{ label: 'Every 24 hours', value: '24h' },
]}
/>
</div>
<Checkbox
checked={settings?.autoUpgradePullServices ?? false}
onChange={(e) => handleAutoUpgradeToggle('autoUpgradePullServices', e.target.checked)}
>
Also pull third-party Docker images (PostgreSQL, Redis, etc.)
</Checkbox>
<Checkbox
checked={settings?.notifyAdminAutoUpgrade ?? true}
onChange={(e) => handleAutoUpgradeToggle('notifyAdminAutoUpgrade', e.target.checked)}
>
Email admins on auto-upgrade completion or failure
</Checkbox>
</>
)}
</Space>
</Card>
{/* Recent Commits Feed */}
{status?.changelog && status.changelog.length > 0 && status.commitsBehind === 0 && (
<Card
size="small"
title={<Space><BranchesOutlined /> Recent Commits</Space>}
style={{ marginBottom: 16 }}
>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
<Timeline
items={status.changelog.map((entry) => ({
color: 'gray',
children: (
<div>
<Space size={4}>
<Text code style={{ fontSize: 12 }}>{entry.hash}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{entry.author}</Text>
{entry.date && (
<Text type="secondary" style={{ fontSize: 12 }}>{formatRelativeTime(entry.date)}</Text>
)}
</Space>
<div><Text style={{ fontSize: 13 }}>{entry.message}</Text></div>
</div>
),
}))}
/>
</div>
</Card>
)}
{/* Upgrade History */}
{history.length > 0 && (
<Card
size="small"
title={<Space><HistoryOutlined /> Upgrade History</Space>}
style={{ marginBottom: 16 }}
>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
<Timeline
items={history.map((entry, i) => ({
color: entry.success ? 'green' : 'red',
children: (
<div key={i}>
<Space size={4} wrap>
<Tag color={entry.success ? 'success' : 'error'} style={{ fontSize: 11 }}>
{entry.success ? 'Success' : 'Failed'}
</Tag>
<Text code style={{ fontSize: 12 }}>{entry.previousCommit}</Text>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text code style={{ fontSize: 12 }}>{entry.newCommit}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{formatRelativeTime(entry.completedAt)}
</Text>
{entry.triggeredBy && (
<Tag style={{ fontSize: 11 }}>
{entry.triggeredBy === 'auto-upgrade' ? 'auto' : entry.triggeredBy}
</Tag>
)}
</Space>
<div>
<Text style={{ fontSize: 13 }}>{entry.message}</Text>
{entry.commitCount > 0 && (
<Text type="secondary" style={{ fontSize: 12 }}> ({entry.commitCount} commit{entry.commitCount !== 1 ? 's' : ''}, {formatDuration(entry.durationSeconds)})</Text>
)}
</div>
{entry.warnings.length > 0 && (
<div style={{ marginTop: 4 }}>
{entry.warnings.map((w, wi) => (
<Text key={wi} type="warning" style={{ fontSize: 12, display: 'block' }}>{w}</Text>
))}
</div>
)}
</div>
),
}))}
/>
</div>
</Card>
)}
{/* Systemd Setup Info */}
<Card
size="small"

View File

@ -74,6 +74,12 @@ const roleColors: Record<UserRole, string> = {
SUPER_ADMIN: 'red',
INFLUENCE_ADMIN: 'volcano',
MAP_ADMIN: 'orange',
BROADCAST_ADMIN: 'gold',
CONTENT_ADMIN: 'lime',
MEDIA_ADMIN: 'purple',
PAYMENTS_ADMIN: 'green',
EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta',
USER: 'blue',
TEMP: 'default',
};
@ -89,8 +95,14 @@ const statusColors: Record<UserStatus, string> = {
const roleOptions: { value: UserRole; label: string }[] = [
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
{ value: 'INFLUENCE_ADMIN', label: 'Influence Admin' },
{ value: 'INFLUENCE_ADMIN', label: 'Advocacy Admin' },
{ value: 'MAP_ADMIN', label: 'Map Admin' },
{ value: 'BROADCAST_ADMIN', label: 'Broadcast Admin' },
{ value: 'CONTENT_ADMIN', label: 'Content Admin' },
{ value: 'MEDIA_ADMIN', label: 'Media Admin' },
{ value: 'PAYMENTS_ADMIN', label: 'Payments Admin' },
{ value: 'EVENTS_ADMIN', label: 'Events Admin' },
{ value: 'SOCIAL_ADMIN', label: 'Social Admin' },
{ value: 'USER', label: 'User' },
{ value: 'TEMP', label: 'Temp' },
];
@ -107,7 +119,7 @@ const statusOptions: { value: UserStatus; label: string }[] = [
export default function UsersPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const { user: currentUser } = useAuthStore();
const isSuperAdmin = currentUser?.role === 'SUPER_ADMIN';
const isSuperAdmin = currentUser ? getUserRoles(currentUser).includes('SUPER_ADMIN') : false;
const [nocodbUrl, setNocodbUrl] = useState<string | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
@ -224,7 +236,7 @@ export default function UsersPage() {
}
};
const handleEdit = async (values: UpdateUserPayload & { expiresAtDate?: dayjs.Dayjs | null }) => {
const handleEdit = async (values: UpdateUserPayload & { expiresAtDate?: dayjs.Dayjs | null; canManageUsers?: boolean }) => {
if (!editingUser) return;
try {
const payload: UpdateUserPayload = { ...values };
@ -234,6 +246,15 @@ export default function UsersPage() {
payload.expiresAt = null;
}
delete (payload as unknown as Record<string, unknown>).expiresAtDate;
// Merge canManageUsers into permissions
if (isSuperAdmin && values.canManageUsers !== undefined) {
const existingPerms = (editingUser.permissions as Record<string, unknown>) || {};
(payload as unknown as Record<string, unknown>).permissions = {
...existingPerms,
canManageUsers: values.canManageUsers,
};
}
delete (payload as unknown as Record<string, unknown>).canManageUsers;
// Remove empty password
if (!payload.password) delete payload.password;
await api.put(`/users/${editingUser.id}`, payload);
@ -387,6 +408,7 @@ export default function UsersPage() {
status: user.status,
expireDays: user.expireDays,
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
canManageUsers: !!(user.permissions as Record<string, unknown> | undefined)?.canManageUsers,
});
setEditDrawerOpen(true);
fetchProvisioningStatus(user.id);
@ -898,6 +920,16 @@ export default function UsersPage() {
</Col>
</Row>
)}
{isSuperAdmin && (
<Form.Item
name="canManageUsers"
label="Can Manage Users"
valuePropName="checked"
help="Allows this admin to create, edit, and delete users"
>
<Switch />
</Form.Item>
)}
</Form>
{editingUser && (

View File

@ -16,10 +16,12 @@ import {
ClockCircleOutlined,
EnvironmentOutlined,
TeamOutlined,
LockOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
import { POLL_STATUS_COLORS, POLL_STATUS_LABELS } from '@/types/api';
@ -29,6 +31,7 @@ export default function PollsListPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const navigate = useNavigate();
const { user } = useAuthStore();
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
const [loading, setLoading] = useState(true);
@ -36,7 +39,7 @@ export default function PollsListPage() {
useEffect(() => {
const fetchPolls = async () => {
try {
const { data } = await axios.get<PollsListResponse>('/api/meeting-planner/public');
const { data } = await api.get<PollsListResponse>('/meeting-planner/public');
setPolls(data.polls);
} catch {
// If unauthorized, try the public listing approach
@ -81,7 +84,10 @@ export default function PollsListPage() {
<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>
<Text strong style={{ fontSize: 16 }}>
{poll.isPrivate && <LockOutlined style={{ marginRight: 6, color: '#faad14' }} />}
{poll.title}
</Text>
</Col>
<Col>
<Tag color={POLL_STATUS_COLORS[poll.status as SchedulingPollStatus]}>
@ -125,7 +131,7 @@ export default function PollsListPage() {
)}
<Button type="primary" size="small" block style={{ marginTop: 8 }}>
Vote Now
{poll.requiresAuth && !user ? 'Sign In to View' : 'Vote Now'}
</Button>
</Space>
</Card>

View File

@ -24,10 +24,11 @@ import {
CheckCircleOutlined,
SendOutlined,
EyeOutlined,
LockOutlined,
} from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import { useParams } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '@/lib/api';
import type {
PollDetailResponse,
PollVoteValue,
@ -48,6 +49,7 @@ export default function SchedulingPollPage() {
const { slug } = useParams<{ slug: string }>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const navigate = useNavigate();
const { user } = useAuthStore();
const [poll, setPoll] = useState<PollDetailResponse | null>(null);
@ -71,7 +73,7 @@ export default function SchedulingPollPage() {
if (!slug) return;
setLoading(true);
try {
const { data } = await axios.get<PollDetailResponse>(`/api/meeting-planner/public/${slug}`);
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/public/${slug}`);
setPoll(data);
// Check if user has already voted (by token or auth)
@ -123,7 +125,7 @@ export default function SchedulingPollPage() {
setSubmitting(true);
try {
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
const { data } = await axios.post(`/api/meeting-planner/public/${slug}/vote`, {
const { data } = await api.post(`/meeting-planner/public/${slug}/vote`, {
voterName: voterName.trim(),
voterEmail: trimmedEmail || undefined,
voterToken: storedToken || undefined,
@ -153,7 +155,7 @@ export default function SchedulingPollPage() {
}
setCommentSubmitting(true);
try {
await axios.post(`/api/meeting-planner/public/${slug}/comment`, {
await api.post(`/meeting-planner/public/${slug}/comment`, {
authorName: commentName.trim(),
content: commentContent.trim(),
});
@ -185,6 +187,27 @@ export default function SchedulingPollPage() {
);
}
if (poll.requiresAuth) {
return (
<div style={{ maxWidth: 500, margin: '0 auto', padding: isMobile ? '40px 16px' : '80px 16px' }}>
<Result
icon={<LockOutlined style={{ color: '#faad14' }} />}
title={poll.title}
subTitle="This poll is private. Please sign in to view the details and participate."
extra={
<Button
type="primary"
size="large"
onClick={() => navigate(`/login?redirect=/poll/${slug}`)}
>
Sign In to View
</Button>
}
/>
</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;

View File

@ -22,7 +22,7 @@ import dayjs, { Dayjs } from 'dayjs';
import { api } from '@/lib/api';
import FeatureGate from '@/components/FeatureGate';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import CalendarTimeGrid from '@/components/calendar/CalendarTimeGrid';
import type { PersonalCalendarItem } from '@/types/api';
const { Title, Text } = Typography;
@ -164,14 +164,13 @@ export default function FriendCalendarPage() {
{isMobile ? (
<div>
<MobileDayView
<CalendarTimeGrid
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
viewMode="day"
currentDate={currentMonth}
onDateSelect={setSelectedDate}
onMonthChange={setCurrentMonth}
onAddItem={() => {}}
onItemClick={handleItemClick}
onNavigate={(dir) => setCurrentMonth((d) => d.add(dir === 'next' ? 1 : -1, 'day'))}
/>
{dateDetailPanel}
</div>

View File

@ -23,6 +23,8 @@ import {
DeleteOutlined,
EditOutlined,
SettingOutlined,
LeftOutlined,
RightOutlined,
} from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { useNavigate } from 'react-router-dom';
@ -30,10 +32,12 @@ import { api } from '@/lib/api';
import FeatureGate from '@/components/FeatureGate';
import CalendarLayerPanel from '@/components/calendar/CalendarLayerPanel';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import CalendarTimeGrid from '@/components/calendar/CalendarTimeGrid';
import CalendarItemModal, { type CalendarItemFormData } from '@/components/calendar/CalendarItemModal';
import CalendarItemDetail from '@/components/calendar/CalendarItemDetail';
import CalendarFeedsPanel from '@/components/calendar/CalendarFeedsPanel';
import CalendarExportPanel from '@/components/calendar/CalendarExportPanel';
import { getDateRangeForView, type CalendarViewMode } from '@/components/calendar/calendarUtils';
import type {
CalendarLayer,
PersonalCalendarItem,
@ -55,12 +59,14 @@ export default function MyCalendarPage() {
const [loading, setLoading] = useState(true);
// View state
const [currentMonth, setCurrentMonth] = useState<Dayjs>(dayjs());
const [viewMode, setViewMode] = useState<CalendarViewMode>(isMobile ? 'day' : 'month');
const [currentDate, setCurrentDate] = useState<Dayjs>(dayjs());
const [selectedDate, setSelectedDate] = useState<string | null>(null);
// Modal state
// Modal / detail state
const [itemModalOpen, setItemModalOpen] = useState(false);
const [editingItem, setEditingItem] = useState<PersonalCalendarItem | null>(null);
const [selectedItem, setSelectedItem] = useState<PersonalCalendarItem | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
// Derived: enabled layer IDs for filtering
@ -69,7 +75,7 @@ export default function MyCalendarPage() {
// Filtered items based on enabled layers
const filteredItems = items.filter((item) => enabledLayerIds.has(item.layerId));
// Items for the selected date
// Items for the selected date (used in month view detail panel)
const selectedDateItems = selectedDate
? filteredItems.filter((item) => item.date === selectedDate)
: [];
@ -86,13 +92,11 @@ export default function MyCalendarPage() {
// Fetch items for the current visible range
const fetchItems = useCallback(async () => {
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
const { startDate, endDate } = getDateRangeForView(viewMode, currentDate);
try {
const { data } = await api.get<PersonalCalendarResponse>('/calendar/my', {
params: { startDate, endDate },
});
// Flatten the dates map into a flat item array
const allItems: PersonalCalendarItem[] = [];
for (const dateGroup of Object.values(data.dates)) {
allItems.push(...dateGroup.items);
@ -101,7 +105,7 @@ export default function MyCalendarPage() {
} catch {
// Empty calendar
}
}, [currentMonth]);
}, [viewMode, currentDate]);
// Initial load
useEffect(() => {
@ -127,7 +131,6 @@ export default function MyCalendarPage() {
const handleUpdateLayer = async (id: string, updates: Partial<CalendarLayer>) => {
try {
await api.patch(`/calendar/layers/${id}`, updates);
// Optimistic update for toggle
setLayers((prev) =>
prev.map((l) => (l.id === id ? { ...l, ...updates } : l)),
);
@ -220,9 +223,14 @@ export default function MyCalendarPage() {
setItemModalOpen(true);
};
// Open modal for editing an existing item
// Show item detail (click on any item)
const handleItemClick = (item: PersonalCalendarItem) => {
setSelectedItem(item);
};
// Open edit modal for a personal item (from detail drawer)
const handleEditItem = (item: PersonalCalendarItem) => {
if (item.type !== 'personal') return; // Only personal items are editable
if (item.type !== 'personal') return;
setEditingItem(item);
setItemModalOpen(true);
};
@ -230,11 +238,54 @@ export default function MyCalendarPage() {
// Date click handler
const handleDateSelect = (date: string) => {
setSelectedDate(date);
// In month view, clicking a date also shows the detail panel
// In grid views, clicking a date opens the add-item modal
if (viewMode !== 'month') {
handleAddItem(date);
}
};
// Month change handler
const handleMonthChange = (month: Dayjs) => {
setCurrentMonth(month);
// Navigation
const handleNavigate = (direction: 'prev' | 'next') => {
const delta = direction === 'next' ? 1 : -1;
switch (viewMode) {
case 'day':
setCurrentDate((d) => d.add(delta, 'day'));
break;
case '3day':
setCurrentDate((d) => d.add(delta * 3, 'day'));
break;
case 'week':
setCurrentDate((d) => d.add(delta * 7, 'day'));
break;
case 'month':
setCurrentDate((d) => d.add(delta, 'month'));
break;
}
};
const handleToday = () => {
setCurrentDate(dayjs());
setSelectedDate(dayjs().format('YYYY-MM-DD'));
};
// Navigation label
const getNavLabel = (): string => {
switch (viewMode) {
case 'day':
return currentDate.format('ddd, MMM D');
case '3day':
return `${currentDate.format('MMM D')} - ${currentDate.add(2, 'day').format('MMM D')}`;
case 'week': {
const start = currentDate.startOf('week');
const end = currentDate.endOf('week');
return start.month() === end.month()
? `${start.format('MMM D')} - ${end.format('D')}`
: `${start.format('MMM D')} - ${end.format('MMM D')}`;
}
case 'month':
return currentDate.format('MMMM YYYY');
}
};
if (loading) {
@ -245,8 +296,8 @@ export default function MyCalendarPage() {
);
}
// Date detail panel (right side on desktop, or inline on mobile)
const dateDetailPanel = selectedDate && (
// Date detail panel (right side on desktop, used only in month view)
const dateDetailPanel = viewMode === 'month' && selectedDate && (
<div
style={{
width: isMobile ? '100%' : 280,
@ -277,8 +328,8 @@ export default function MyCalendarPage() {
dataSource={selectedDateItems}
renderItem={(item) => (
<List.Item
style={{ padding: '8px 0', cursor: item.type === 'personal' ? 'pointer' : 'default' }}
onClick={() => handleEditItem(item)}
style={{ padding: '8px 0', cursor: 'pointer' }}
onClick={() => handleItemClick(item)}
actions={
item.type === 'personal'
? [
@ -361,13 +412,13 @@ export default function MyCalendarPage() {
return (
<FeatureGate feature="enableSocialCalendar">
<div style={{ padding: '12px 0' }}>
{/* Header */}
{/* Header row 1: Tab selector + settings/add */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 16,
marginBottom: 8,
}}
>
<Space>
@ -395,54 +446,155 @@ export default function MyCalendarPage() {
</Space>
</div>
{isMobile ? (
/* Mobile layout: MobileDayView with layer toggles at top */
<div>
<CalendarLayerPanel
layers={layers}
compact
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
onCreate={handleCreateLayer}
onUpdate={handleUpdateLayer}
onDelete={handleDeleteLayer}
{/* Header row 2: Navigation + view mode */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
flexWrap: 'wrap',
gap: 8,
}}
>
<Space>
<Button
icon={<LeftOutlined />}
size="small"
onClick={() => handleNavigate('prev')}
/>
<MobileDayView
items={filteredItems}
currentMonth={currentMonth}
selectedDate={selectedDate}
onDateSelect={handleDateSelect}
onMonthChange={handleMonthChange}
onItemClick={handleEditItem}
onAddItem={handleAddItem}
<Button size="small" onClick={handleToday}>
Today
</Button>
<Button
icon={<RightOutlined />}
size="small"
onClick={() => handleNavigate('next')}
/>
{dateDetailPanel}
</div>
) : (
/* Desktop layout: layer panel | calendar | date detail */
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ width: 240, flexShrink: 0, paddingRight: 16 }}>
<Text strong style={{ fontSize: 15, marginLeft: 4 }}>
{getNavLabel()}
</Text>
</Space>
<Segmented
size="small"
options={[
{ label: 'Day', value: 'day' },
{ label: isMobile ? '3D' : '3 Day', value: '3day' },
{ label: 'Week', value: 'week' },
{ label: 'Month', value: 'month' },
]}
value={viewMode}
onChange={(val) => setViewMode(val as CalendarViewMode)}
/>
</div>
{viewMode === 'month' ? (
/* Month view: layer panel + calendar + date detail */
isMobile ? (
<div>
<CalendarLayerPanel
layers={layers}
compact
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
onCreate={handleCreateLayer}
onUpdate={handleUpdateLayer}
onDelete={handleDeleteLayer}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={filteredItems}
currentMonth={currentMonth}
currentMonth={currentDate}
selectedDate={selectedDate}
onDateSelect={(date) => setSelectedDate(date)}
onItemClick={handleItemClick}
onMonthChange={setCurrentDate}
/>
{dateDetailPanel}
</div>
) : (
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ width: 240, flexShrink: 0, paddingRight: 16 }}>
<CalendarLayerPanel
layers={layers}
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
onCreate={handleCreateLayer}
onUpdate={handleUpdateLayer}
onDelete={handleDeleteLayer}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<PersonalCalendarView
items={filteredItems}
currentMonth={currentDate}
selectedDate={selectedDate}
onDateSelect={(date) => setSelectedDate(date)}
onItemClick={handleItemClick}
onMonthChange={setCurrentDate}
/>
</div>
{dateDetailPanel}
</div>
)
) : (
/* Day / 3-Day / Week views: layer panel + time grid */
isMobile ? (
<div>
<CalendarLayerPanel
layers={layers}
compact
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
onCreate={handleCreateLayer}
onUpdate={handleUpdateLayer}
onDelete={handleDeleteLayer}
/>
<CalendarTimeGrid
items={filteredItems}
viewMode={viewMode}
currentDate={currentDate}
onDateSelect={handleDateSelect}
onMonthChange={handleMonthChange}
onItemClick={handleEditItem}
onItemClick={handleItemClick}
onNavigate={handleNavigate}
/>
</div>
{dateDetailPanel}
</div>
) : (
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ width: 240, flexShrink: 0, paddingRight: 16 }}>
<CalendarLayerPanel
layers={layers}
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
onCreate={handleCreateLayer}
onUpdate={handleUpdateLayer}
onDelete={handleDeleteLayer}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<CalendarTimeGrid
items={filteredItems}
viewMode={viewMode}
currentDate={currentDate}
onDateSelect={handleDateSelect}
onItemClick={handleItemClick}
onNavigate={handleNavigate}
/>
</div>
</div>
)
)}
{/* Item detail drawer */}
<CalendarItemDetail
item={selectedItem}
open={!!selectedItem}
onClose={() => setSelectedItem(null)}
onEdit={(item) => {
setSelectedItem(null);
handleEditItem(item);
}}
onDelete={(item) => {
setSelectedItem(null);
confirmDeleteItem(item);
}}
/>
{/* Settings drawer */}
<Drawer
title="Calendar Settings"

View File

@ -25,7 +25,7 @@ import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth.store';
import FeatureGate from '@/components/FeatureGate';
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
import MobileDayView from '@/components/calendar/MobileDayView';
import CalendarTimeGrid from '@/components/calendar/CalendarTimeGrid';
import SharedViewMembersPanel from '@/components/calendar/SharedViewMembersPanel';
import AvailabilityFinder from '@/components/calendar/AvailabilityFinder';
import CalendarComments from '@/components/calendar/CalendarComments';
@ -258,14 +258,13 @@ export default function SharedCalendarViewPage() {
label: 'Calendar',
children: (
<>
<MobileDayView
<CalendarTimeGrid
items={items}
currentMonth={currentMonth}
selectedDate={selectedDate}
viewMode="day"
currentDate={currentMonth}
onDateSelect={setSelectedDate}
onMonthChange={setCurrentMonth}
onAddItem={() => {}}
onItemClick={handleItemClick}
onNavigate={(dir) => setCurrentMonth((d) => d.add(dir === 'next' ? 1 : -1, 'day'))}
/>
{dateDetailPanel}
</>

View File

@ -13,7 +13,7 @@ export interface AppOutletContext {
setPageHeader: (config: PageHeaderConfig | null) => void;
}
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'USER' | 'TEMP';
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'USER' | 'TEMP';
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
@ -87,6 +87,7 @@ export interface UpdateUserPayload {
status?: UserStatus;
expiresAt?: string | null;
expireDays?: number;
permissions?: Record<string, unknown>;
}
export interface UsersListParams {
@ -97,7 +98,19 @@ export interface UsersListParams {
status?: UserStatus;
}
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN'];
// Module-specific role groups
export const INFLUENCE_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN'];
export const MAP_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN'];
export const BROADCAST_ROLES: UserRole[] = ['SUPER_ADMIN', 'BROADCAST_ADMIN'];
export const CONTENT_ROLES: UserRole[] = ['SUPER_ADMIN', 'CONTENT_ADMIN'];
export const MEDIA_ROLES: UserRole[] = ['SUPER_ADMIN', 'MEDIA_ADMIN'];
export const PAYMENTS_ROLES: UserRole[] = ['SUPER_ADMIN', 'PAYMENTS_ADMIN'];
export const EVENTS_ROLES: UserRole[] = ['SUPER_ADMIN', 'EVENTS_ADMIN'];
export const SOCIAL_ROLES: UserRole[] = ['SUPER_ADMIN', 'SOCIAL_ADMIN'];
export const SYSTEM_ROLES: UserRole[] = ['SUPER_ADMIN'];
export const SCHEDULING_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN', 'EVENTS_ADMIN'];
// --- User Provisioning ---
@ -783,7 +796,7 @@ export const CUT_CATEGORY_COLORS: Record<CutCategory, string> = {
DISTRICT: 'purple',
};
export const MAP_ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN'];
export const MAP_ADMIN_ROLES: UserRole[] = MAP_ROLES;
// --- Map / Shifts ---
@ -1153,6 +1166,7 @@ export interface SiteSettings {
enableMeetingPlanner: boolean;
enableTicketedEvents: boolean;
enableSocialCalendar: boolean;
enableDocsCollaboration: boolean;
requireEventApproval: boolean;
autoSyncPeopleToMap: boolean;
// SMS connection config (only present from admin endpoint)
@ -1162,6 +1176,11 @@ export interface SiteSettings {
smsTailscaleTailnet?: string;
smsTailscaleDeviceId?: string;
smsTailscaleDeviceName?: string;
// Auto-upgrade settings (only present from admin endpoint)
enableAutoUpgrade?: boolean;
autoUpgradeSchedule?: 'daily-3am' | 'daily-4am' | 'daily-5am' | 'weekly-sun-3am' | 'weekly-mon-3am' | '12h' | '24h';
autoUpgradePullServices?: boolean;
notifyAdminAutoUpgrade?: boolean;
// Navigation configuration
navConfig: NavConfig | null;
// User Provisioning
@ -2895,7 +2914,9 @@ export interface SchedulingPoll {
convertedGancioEventId: number | null;
votingDeadline: string | null;
allowAnonymous: boolean;
isPrivate: boolean;
notifyOnVote: boolean;
requiresAuth?: boolean;
createdByUserId: string;
createdBy?: { id: string; name: string | null; email: string };
createdAt: string;
@ -2961,6 +2982,11 @@ export interface UpgradeResult {
durationSeconds: number;
warnings: string[];
completedAt: string;
triggeredBy?: string;
}
export interface UpgradeHistoryResponse {
history: UpgradeResult[];
}
export interface UpgradeStatusResponse {

View File

@ -26,6 +26,7 @@ export default defineConfig({
// Use env var with fallback: Docker uses container name, local uses localhost
target: process.env.VITE_API_URL || 'http://localhost:4000',
changeOrigin: true,
ws: true, // WebSocket passthrough for docs collaboration
},
'/media/public': {
// Public media routes: rewrite to /api/public (matches Fastify prefix: '/api')

197
api/package-lock.json generated
View File

@ -11,6 +11,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@hocuspocus/server": "^3.4.4",
"@prisma/client": "^6.3.0",
"@types/mime-types": "^3.0.1",
"bcryptjs": "^2.4.3",
@ -30,10 +31,10 @@
"ioredis": "^5.4.2",
"jsonwebtoken": "^9.0.2",
"mime-types": "^3.0.2",
"multer": "^2.0.2",
"multer": "^2.1.1",
"node-addon-api": "^8.5.0",
"node-ical": "^0.25.5",
"nodemailer": "^6.9.16",
"nodemailer": "^8.0.1",
"pg": "^8.18.0",
"proj4": "^2.20.2",
"prom-client": "^15.1.3",
@ -42,7 +43,9 @@
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"yjs": "^13.6.29",
"zod": "^3.24.1"
},
"devDependencies": {
@ -53,9 +56,10 @@
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.0.0",
"@types/node": "^22.19.11",
"@types/nodemailer": "^6.4.17",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.9",
"prisma": "^6.3.0",
"tsx": "^4.19.2",
@ -1179,6 +1183,31 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/@hocuspocus/common": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-3.4.4.tgz",
"integrity": "sha512-RykIJ0tsHHMP4Xk+4UCbc7SO5LgGxGUSTdbh6anJEsaALAyqinf1Nn5HYuMjLPolAmsar1v++m9zufR09NLpXA==",
"dependencies": {
"lib0": "^0.2.87"
}
},
"node_modules/@hocuspocus/server": {
"version": "3.4.4",
"resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-3.4.4.tgz",
"integrity": "sha512-UV+oaONAejOzeYgUygNcgsc8RdZvSokVvAxluZJIisLACpRO/VsseQ5lWKDRwLd7Fn6+rHWDH3hGuQ1fdX1Ycg==",
"dependencies": {
"@hocuspocus/common": "^3.4.4",
"async-lock": "^1.3.1",
"async-mutex": "^0.5.0",
"kleur": "^4.1.4",
"lib0": "^0.2.47",
"ws": "^8.5.0"
},
"peerDependencies": {
"y-protocols": "^1.0.6",
"yjs": "^13.6.8"
}
},
"node_modules/@img/colour": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
@ -1943,9 +1972,9 @@
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.22",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.22.tgz",
"integrity": "sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==",
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"dependencies": {
"@types/node": "*"
@ -2007,6 +2036,15 @@
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
@ -2135,6 +2173,19 @@
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
},
"node_modules/async-lock": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz",
"integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="
},
"node_modules/async-mutex": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz",
"integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==",
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
@ -3969,6 +4020,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/isomorphic.js": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz",
"integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==",
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
@ -4065,11 +4125,39 @@
"safe-buffer": "^5.0.1"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/kuler": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
},
"node_modules/lib0": {
"version": "0.2.117",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz",
"integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==",
"dependencies": {
"isomorphic.js": "^0.2.4"
},
"bin": {
"0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js",
"0gentesthtml": "bin/gentesthtml.js",
"0serve": "bin/0serve.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
@ -4295,14 +4383,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/minimist": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
@ -4311,17 +4391,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dependencies": {
"minimist": "^1.2.6"
},
"bin": {
"mkdirp": "bin/cmd.js"
}
},
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
@ -4357,21 +4426,21 @@
}
},
"node_modules/multer": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
"license": "MIT",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"mkdirp": "^0.5.6",
"object-assign": "^4.1.1",
"type-is": "^1.6.18",
"xtend": "^4.0.2"
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/negotiator": {
@ -4428,9 +4497,9 @@
}
},
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"engines": {
"node": ">=6.0.0"
}
@ -5655,6 +5724,26 @@
"node": ">=8"
}
},
"node_modules/ws": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@ -5663,6 +5752,26 @@
"node": ">=0.4"
}
},
"node_modules/y-protocols": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz",
"integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==",
"peer": true,
"dependencies": {
"lib0": "^0.2.85"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
},
"peerDependencies": {
"yjs": "^13.0.0"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
@ -5716,6 +5825,22 @@
"node": ">=6"
}
},
"node_modules/yjs": {
"version": "13.6.29",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz",
"integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==",
"dependencies": {
"lib0": "^0.2.99"
},
"engines": {
"node": ">=16.0.0",
"npm": ">=8.0.0"
},
"funding": {
"type": "GitHub Sponsors ❤",
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",

View File

@ -19,6 +19,7 @@
"@fastify/cors": "^11.2.0",
"@fastify/multipart": "^9.4.0",
"@fastify/static": "^9.0.0",
"@hocuspocus/server": "^3.4.4",
"@prisma/client": "^6.3.0",
"@types/mime-types": "^3.0.1",
"bcryptjs": "^2.4.3",
@ -38,10 +39,10 @@
"ioredis": "^5.4.2",
"jsonwebtoken": "^9.0.2",
"mime-types": "^3.0.2",
"multer": "^2.0.2",
"multer": "^2.1.1",
"node-addon-api": "^8.5.0",
"node-ical": "^0.25.5",
"nodemailer": "^6.9.16",
"nodemailer": "^8.0.1",
"pg": "^8.18.0",
"proj4": "^2.20.2",
"prom-client": "^15.1.3",
@ -50,7 +51,9 @@
"sharp": "^0.34.5",
"stripe": "^20.3.1",
"winston": "^3.17.0",
"ws": "^8.19.0",
"yaml": "^2.8.2",
"yjs": "^13.6.29",
"zod": "^3.24.1"
},
"devDependencies": {
@ -61,9 +64,10 @@
"@types/jsonwebtoken": "^9.0.7",
"@types/multer": "^2.0.0",
"@types/node": "^22.19.11",
"@types/nodemailer": "^6.4.17",
"@types/nodemailer": "^7.0.11",
"@types/pg": "^8.16.0",
"@types/qrcode": "^1.5.6",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.9",
"prisma": "^6.3.0",
"tsx": "^4.19.2",

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "scheduling_polls" ADD COLUMN "is_private" BOOLEAN NOT NULL DEFAULT false;

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "auto_upgrade_pull_services" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "auto_upgrade_schedule" TEXT NOT NULL DEFAULT 'daily-3am',
ADD COLUMN "enable_auto_upgrade" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "notify_admin_auto_upgrade" BOOLEAN NOT NULL DEFAULT true;

View File

@ -0,0 +1,7 @@
-- AlterEnum
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'BROADCAST_ADMIN';
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'CONTENT_ADMIN';
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'MEDIA_ADMIN';
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'PAYMENTS_ADMIN';
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'EVENTS_ADMIN';
ALTER TYPE "UserRole" ADD VALUE IF NOT EXISTS 'SOCIAL_ADMIN';

View File

@ -0,0 +1,14 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "UserRole" ADD VALUE 'BROADCAST_ADMIN';
ALTER TYPE "UserRole" ADD VALUE 'CONTENT_ADMIN';
ALTER TYPE "UserRole" ADD VALUE 'MEDIA_ADMIN';
ALTER TYPE "UserRole" ADD VALUE 'PAYMENTS_ADMIN';
ALTER TYPE "UserRole" ADD VALUE 'EVENTS_ADMIN';
ALTER TYPE "UserRole" ADD VALUE 'SOCIAL_ADMIN';

View File

@ -0,0 +1,16 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN IF NOT EXISTS "enable_docs_collaboration" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE IF NOT EXISTS "doc_collab_state" (
"id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"state" BYTEA NOT NULL,
"updated_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "doc_collab_state_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "doc_collab_state_document_id_key" ON "doc_collab_state"("document_id");

View File

@ -15,6 +15,12 @@ enum UserRole {
SUPER_ADMIN
INFLUENCE_ADMIN
MAP_ADMIN
BROADCAST_ADMIN
CONTENT_ADMIN
MEDIA_ADMIN
PAYMENTS_ADMIN
EVENTS_ADMIN
SOCIAL_ADMIN
USER
TEMP
}
@ -935,6 +941,7 @@ model SiteSettings {
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
requireEventApproval Boolean @default(true) @map("require_event_approval")
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
@ -974,6 +981,12 @@ model SiteSettings {
reengagementInactiveDays Int @default(30) @map("reengagement_inactive_days")
reengagementCooldownDays Int @default(30) @map("reengagement_cooldown_days")
// Auto-upgrade settings
enableAutoUpgrade Boolean @default(false) @map("enable_auto_upgrade")
autoUpgradeSchedule String @default("daily-3am") @map("auto_upgrade_schedule")
autoUpgradePullServices Boolean @default(false) @map("auto_upgrade_pull_services")
notifyAdminAutoUpgrade Boolean @default(true) @map("notify_admin_auto_upgrade")
// Navigation configuration (JSON: { items: NavItem[] })
navConfig Json? @map("nav_config")
@ -4383,6 +4396,7 @@ model SchedulingPoll {
convertedGancioEventId Int? @map("converted_gancio_event_id")
votingDeadline DateTime? @map("voting_deadline")
allowAnonymous Boolean @default(true) @map("allow_anonymous")
isPrivate Boolean @default(false) @map("is_private")
notifyOnVote Boolean @default(true) @map("notify_on_vote")
createdByUserId String @map("created_by_user_id")
createdBy User @relation("PollCreator", fields: [createdByUserId], references: [id])
@ -5093,3 +5107,17 @@ model CalendarExportToken {
@@index([userId], map: "idx_calendar_export_tokens_user")
@@map("calendar_export_tokens")
}
// ============================================================================
// DOCS COLLABORATION
// ============================================================================
model DocCollabState {
id String @id @default(cuid())
documentId String @unique @map("document_id") // file path, e.g. "admin/index.md"
state Bytes // Y.Doc binary state
updatedAt DateTime @updatedAt @map("updated_at")
createdAt DateTime @default(now()) @map("created_at")
@@map("doc_collab_state")
}

View File

@ -10,6 +10,12 @@ export function requireRole(...roles: UserRole[]) {
// Check multi-role array (falls back to single role via auth middleware)
const userRoles = req.user.roles || [req.user.role];
// SUPER_ADMIN bypasses all role checks
if (userRoles.includes(UserRole.SUPER_ADMIN)) {
return next();
}
const hasRole = userRoles.some(r => roles.includes(r));
if (!hasRole) {

View File

@ -215,6 +215,18 @@ export const authService = {
throw new AppError(401, 'Refresh token not found', 'INVALID_REFRESH_TOKEN');
}
// Check user status — banned/inactive users must not get new tokens
if (stored.user.status !== UserStatus.ACTIVE) {
await prisma.refreshToken.delete({ where: { id: stored.id } });
throw new AppError(401, 'Account is not active', 'ACCOUNT_INACTIVE');
}
// Check account expiry
if (stored.user.expiresAt && stored.user.expiresAt < new Date()) {
await prisma.refreshToken.delete({ where: { id: stored.id } });
throw new AppError(401, 'Account has expired', 'ACCOUNT_EXPIRED');
}
if (stored.expiresAt < new Date()) {
await prisma.refreshToken.delete({ where: { id: stored.id } });
throw new AppError(401, 'Refresh token expired', 'REFRESH_TOKEN_EXPIRED');

View File

@ -1,6 +1,7 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { EVENTS_ROLES } from '../../utils/roles';
import { validate } from '../../middleware/validate';
import { adminCalendarService } from './admin-calendar.service';
import { createAdminViewSchema, updateAdminViewSchema } from './admin-calendar.schemas';
@ -9,7 +10,7 @@ import { dateRangeQuerySchema } from './shared-calendar.schemas';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN'));
router.use(requireRole(...EVENTS_ROLES));
// List admin calendar views
router.get('/', async (req, res, next) => {

View File

@ -1,4 +1,6 @@
import crypto from 'crypto';
import dns from 'dns/promises';
import { URL } from 'url';
import {
CalendarLayerType,
CalendarVisibility,
@ -20,6 +22,71 @@ const FETCH_TIMEOUT_MS = 30_000;
const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB
const MATERIALIZE_MONTHS = 3;
// SSRF protection: block requests to private/reserved IP ranges and internal hosts
const BLOCKED_HOSTNAMES = new Set([
'localhost', '0.0.0.0', '[::]', '[::1]',
// Common Docker internal hostnames
'changemaker-v2-postgres', 'redis-changemaker', 'changemaker-v2-api',
'changemaker-v2-admin', 'changemaker-v2-nginx', 'changemaker-v2-nocodb',
'listmonk-app', 'listmonk-db', 'mailhog-changemaker',
]);
function isPrivateIP(ip: string): boolean {
// IPv4 private/reserved ranges
if (ip.startsWith('10.')) return true;
if (ip.startsWith('127.')) return true;
if (ip.startsWith('169.254.')) return true; // Link-local / cloud metadata
if (ip.startsWith('172.')) {
const second = parseInt(ip.split('.')[1], 10);
if (second >= 16 && second <= 31) return true;
}
if (ip.startsWith('192.168.')) return true;
if (ip === '0.0.0.0') return true;
// IPv6 private/reserved
if (ip === '::1' || ip === '::') return true;
if (ip.startsWith('fc') || ip.startsWith('fd')) return true; // ULA
if (ip.startsWith('fe80')) return true; // Link-local
return false;
}
async function validateFeedUrl(rawUrl: string): Promise<void> {
let parsed: URL;
try {
parsed = new URL(rawUrl);
} catch {
throw new AppError(400, 'Invalid URL format', 'INVALID_FEED_URL');
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
throw new AppError(400, 'Only http and https URLs are allowed', 'INVALID_FEED_URL');
}
const hostname = parsed.hostname.toLowerCase();
if (BLOCKED_HOSTNAMES.has(hostname)) {
throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
}
// Resolve DNS and check all resolved IPs
try {
const addrs4 = await dns.resolve4(hostname).catch(() => [] as string[]);
const addrs6 = await dns.resolve6(hostname).catch(() => [] as string[]);
const allAddrs = [...addrs4, ...addrs6];
if (allAddrs.length === 0) {
throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
}
for (const addr of allAddrs) {
if (isPrivateIP(addr)) {
throw new AppError(400, 'This URL is not allowed', 'BLOCKED_FEED_URL');
}
}
} catch (err) {
if (err instanceof AppError) throw err;
throw new AppError(400, 'Could not resolve feed URL hostname', 'FEED_URL_UNREACHABLE');
}
}
// Map CalendarFeedInterval to milliseconds
const INTERVAL_MS: Record<CalendarFeedInterval, number> = {
FIFTEEN_MIN: 15 * 60 * 1000,
@ -42,6 +109,9 @@ export const feedService = {
},
async createFeed(userId: string, data: CreateFeedInput) {
// SSRF protection: validate URL before making any request
await validateFeedUrl(data.url);
// Validate URL is reachable
try {
const controller = new AbortController();
@ -49,10 +119,10 @@ export const feedService = {
const res = await fetch(data.url, {
method: 'HEAD',
signal: controller.signal,
redirect: 'follow',
redirect: 'manual', // Don't follow redirects (prevent SSRF via open redirects)
});
clearTimeout(timeout);
if (!res.ok && res.status !== 405) {
if (!res.ok && res.status !== 405 && !(res.status >= 300 && res.status < 400)) {
throw new AppError(400, `Feed URL returned status ${res.status}`, 'FEED_URL_UNREACHABLE');
}
} catch (err) {
@ -114,7 +184,11 @@ export const feedService = {
data: { name: data.name },
});
}
if (data.url !== undefined) updateData.url = data.url;
if (data.url !== undefined) {
// SSRF protection: validate new URL before saving
await validateFeedUrl(data.url);
updateData.url = data.url;
}
if (data.refreshInterval !== undefined) {
updateData.refreshInterval = data.refreshInterval as CalendarFeedInterval;
}
@ -148,9 +222,12 @@ export const feedService = {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
// Re-validate the stored URL in case it was changed outside the update flow
await validateFeedUrl(feed.url);
const response = await fetch(feed.url, {
signal: controller.signal,
redirect: 'follow',
redirect: 'manual', // Don't follow redirects (SSRF protection)
headers: { 'User-Agent': 'Changemaker-Calendar/1.0' },
});
clearTimeout(timeout);

View File

@ -1,6 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { ADMIN_ROLES } from '../../utils/roles';
import {
getDashboardSummary,
getSystemInfo,
@ -25,7 +26,7 @@ import {
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
router.use(requireRole(...ADMIN_ROLES));
// GET /api/dashboard/summary — platform counts
router.get('/summary', async (_req: Request, res: Response, next: NextFunction) => {

View File

@ -1,13 +1,11 @@
import { Router } from 'express';
import { UserRole } from '@prisma/client';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { docsAnalyticsRateLimit } from '../../middleware/rate-limit';
import { docsAnalyticsService } from './docs-analytics.service';
import { trackPageViewSchema, analyticsQuerySchema } from './docs-analytics.schemas';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { CONTENT_ROLES } from '../../utils/roles';
// --- Public Router (no auth) ---
export const docsAnalyticsPublicRouter = Router();
@ -47,7 +45,7 @@ docsAnalyticsPublicRouter.post(
// --- Admin Router (auth required) ---
export const docsAnalyticsAdminRouter = Router();
docsAnalyticsAdminRouter.use(authenticate);
docsAnalyticsAdminRouter.use(requireRole(...ADMIN_ROLES));
docsAnalyticsAdminRouter.use(requireRole(...CONTENT_ROLES));
// GET /api/docs-analytics/summary?days=30
docsAnalyticsAdminRouter.get(

View File

@ -1,5 +1,4 @@
import { Router } from 'express';
import { UserRole } from '@prisma/client';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
@ -19,8 +18,7 @@ import {
moderationQuerySchema,
} from './docs-comments.schemas';
import { env } from '../../config/env';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { CONTENT_ROLES } from '../../utils/roles';
// --- Public Router (CORS override for docs origin) ---
export const docsCommentsPublicRouter = Router();
@ -195,7 +193,7 @@ docsCommentsPublicRouter.get('/oauth/config', async (_req, res) => {
// --- Admin Router (auth required) ---
export const docsCommentsAdminRouter = Router();
docsCommentsAdminRouter.use(authenticate);
docsCommentsAdminRouter.use(requireRole(...ADMIN_ROLES));
docsCommentsAdminRouter.use(requireRole(...CONTENT_ROLES));
// GET /api/docs-comments/moderation?status=PENDING&page=1
docsCommentsAdminRouter.get(

View File

@ -0,0 +1,351 @@
import { IncomingMessage } from 'http';
import crypto from 'crypto';
import type WebSocket from 'ws';
import jwt from 'jsonwebtoken';
import { UserRole } from '@prisma/client';
import * as Y from 'yjs';
import { Hocuspocus } from '@hocuspocus/server';
import type { Extension } from '@hocuspocus/server';
import { env } from '../../config/env';
import { prisma } from '../../config/database';
import { redis } from '../../config/redis';
import { logger } from '../../utils/logger';
import { CONTENT_ROLES } from '../../utils/roles';
import { docsFilesService } from './docs-files.service';
// --- Metrics ---
import { Gauge } from 'prom-client';
const collabConnections = new Gauge({
name: 'cm_docs_collab_connections',
help: 'Number of active docs collaboration WebSocket connections',
});
const collabDocuments = new Gauge({
name: 'cm_docs_collab_documents',
help: 'Number of active collaboratively-edited documents',
});
// --- Connection tracking ---
const connectionsPerUser = new Map<string, number>();
const MAX_CONNECTIONS_PER_USER = 5;
const MAX_CONCURRENT_DOCUMENTS = 50;
const MAX_DOC_SIZE_BYTES = 5 * 1024 * 1024; // 5MB
// --- JWT token payload ---
interface TokenPayload {
id: string;
email: string;
role: UserRole;
roles?: UserRole[];
}
// --- Deterministic color from user ID ---
const COLLAB_COLORS = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E9',
'#F0B27A', '#82E0AA', '#F1948A', '#AED6F1', '#D7BDE2',
];
function getUserColor(userId: string): string {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash + userId.charCodeAt(i)) | 0;
}
return COLLAB_COLORS[Math.abs(hash) % COLLAB_COLORS.length];
}
// --- Redis cache key helper ---
function fileCacheKey(relativePath: string): string {
const hash = crypto.createHash('sha256').update(relativePath).digest('hex').substring(0, 16);
return `DOCS_CACHE:file:${hash}`;
}
// --- Hocuspocus extension with hooks ---
const docsExtension: Extension = {
priority: 1,
async onAuthenticate(data) {
const { token, documentName } = data;
if (!token) {
throw new Error('Authentication required');
}
// Verify JWT
let payload: TokenPayload;
try {
payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
} catch {
throw new Error('Invalid or expired token');
}
const roles = payload.roles || [payload.role];
// Check CONTENT_ROLES for write access
const hasWriteAccess = roles.some(r => (CONTENT_ROLES as string[]).includes(r));
if (!hasWriteAccess) {
// Allow read-only for any authenticated non-TEMP user
if (roles.includes(UserRole.TEMP)) {
throw new Error('TEMP users cannot access collaboration');
}
data.connectionConfig.readOnly = true;
}
// Validate document path (prevent path traversal)
try {
docsFilesService.safeResolve(documentName);
} catch {
throw new Error('Invalid document path');
}
// Rate limit: max connections per user
const currentCount = connectionsPerUser.get(payload.id) || 0;
if (currentCount >= MAX_CONNECTIONS_PER_USER) {
throw new Error('Too many concurrent connections');
}
// Rate limit: max concurrent documents
if (hocuspocus.getDocumentsCount() >= MAX_CONCURRENT_DOCUMENTS) {
// Only block if this is a NEW document (not joining existing)
if (!hocuspocus.documents.has(documentName)) {
throw new Error('Too many concurrent documents');
}
}
// Track connection
connectionsPerUser.set(payload.id, currentCount + 1);
// Look up user name from DB
let userName = payload.email.split('@')[0];
try {
const user = await prisma.user.findUnique({
where: { id: payload.id },
select: { name: true },
});
if (user?.name) userName = user.name;
} catch {
// Fall back to email prefix
}
// Set context for use in other hooks
data.context.user = {
id: payload.id,
email: payload.email,
name: userName,
color: getUserColor(payload.id),
roles,
};
logger.info(`Docs collab: ${userName} connected to ${documentName}`);
},
async onLoadDocument(data) {
const { document, documentName } = data;
// Try loading persisted Y.Doc state from DB
try {
const stored = await prisma.docCollabState.findUnique({
where: { documentId: documentName },
});
if (stored) {
const stateVector = new Uint8Array(stored.state);
Y.applyUpdate(document, stateVector);
logger.debug(`Docs collab: loaded persisted state for ${documentName} (${stateVector.length} bytes)`);
return;
}
} catch (err) {
logger.warn(`Docs collab: failed to load persisted state for ${documentName}`, err);
}
// No persisted state — seed from disk
try {
const content = await docsFilesService.readFileContent(documentName);
const yText = document.getText('content');
// Only seed if the Y.Text is empty (first load)
if (yText.length === 0) {
yText.insert(0, content);
}
logger.debug(`Docs collab: seeded from disk for ${documentName} (${content.length} chars)`);
} catch (err) {
logger.warn(`Docs collab: failed to read file from disk for ${documentName}`, err);
// File might not exist yet — that's OK, start with empty doc
}
},
async onChange(data) {
const { document, documentName } = data;
// Size guard
const yText = document.getText('content');
const textLength = yText.length;
if (textLength > MAX_DOC_SIZE_BYTES) {
logger.warn(`Docs collab: document ${documentName} exceeds size limit (${textLength} chars)`);
return;
}
// Write plaintext to disk (debounced by Hocuspocus's built-in debounce)
const content = yText.toString();
try {
await docsFilesService.writeFileContent(documentName, content);
// Invalidate Redis file cache
try {
await redis.del(fileCacheKey(documentName));
} catch { /* ignore */ }
logger.debug(`Docs collab: wrote ${documentName} to disk (${content.length} chars)`);
} catch (err) {
logger.error(`Docs collab: failed to write ${documentName} to disk`, err);
}
},
async onStoreDocument(data) {
const { document, documentName } = data;
// Persist Y.Doc binary state to PostgreSQL
try {
const state = Y.encodeStateAsUpdate(document);
await prisma.docCollabState.upsert({
where: { documentId: documentName },
update: {
state: Buffer.from(state),
},
create: {
documentId: documentName,
state: Buffer.from(state),
},
});
logger.debug(`Docs collab: persisted state for ${documentName}`);
} catch (err) {
logger.error(`Docs collab: failed to persist state for ${documentName}`, err);
}
},
async onDisconnect(data) {
const { documentName, context } = data;
const userId = context?.user?.id;
if (userId) {
const count = connectionsPerUser.get(userId) || 0;
if (count <= 1) {
connectionsPerUser.delete(userId);
} else {
connectionsPerUser.set(userId, count - 1);
}
}
logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} disconnected from ${documentName}`);
// Update metrics
collabConnections.set(hocuspocus.getConnectionsCount());
collabDocuments.set(hocuspocus.getDocumentsCount());
},
async connected(data) {
const { documentName, context } = data;
// Awareness is set client-side; we just log here
logger.debug(`Docs collab: ${context?.user?.name || 'unknown'} fully synced on ${documentName}`);
// Update metrics
collabConnections.set(hocuspocus.getConnectionsCount());
collabDocuments.set(hocuspocus.getDocumentsCount());
},
};
// --- Create Hocuspocus instance ---
const hocuspocus = new Hocuspocus({
name: 'changemaker-docs',
quiet: true,
debounce: 1000, // Debounce disk writes by 1s
maxDebounce: 5000, // Force write every 5s at most
timeout: 30000, // 30s ping timeout
extensions: [docsExtension],
});
/**
* Handle an incoming WebSocket connection for docs collaboration.
* Called from server.ts after the HTTP WS upgrade.
*/
function handleConnection(
ws: WebSocket,
request: IncomingMessage,
context: { documentName: string; token: string },
): void {
// Hocuspocus expects the token in the URL search params for onAuthenticate
// We manually set it via the context's requestParameters
const url = new URL(request.url || '', `http://${request.headers.host}`);
url.searchParams.set('token', context.token);
request.url = url.pathname + url.search;
hocuspocus.handleConnection(ws, request, {
// Pass initial context
documentName: context.documentName,
});
}
/**
* Invalidate a document's collaboration state.
* Called when files are modified externally (rename, delete, PUT endpoint).
*/
async function invalidateDocument(documentName: string): Promise<void> {
try {
// Delete persisted state
await prisma.docCollabState.deleteMany({
where: { documentId: documentName },
});
// Close active connections for this document so clients reload
hocuspocus.closeConnections(documentName);
logger.debug(`Docs collab: invalidated document ${documentName}`);
} catch (err) {
logger.warn(`Docs collab: failed to invalidate document ${documentName}`, err);
}
}
/**
* Clean up stale DocCollabState records (older than 7 days with no corresponding file).
*/
async function cleanupStaleStates(): Promise<void> {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
try {
const staleRecords = await prisma.docCollabState.findMany({
where: { updatedAt: { lt: sevenDaysAgo } },
select: { id: true, documentId: true },
});
let cleaned = 0;
for (const record of staleRecords) {
try {
// Check if file still exists on disk
docsFilesService.safeResolve(record.documentId);
await docsFilesService.readFileContent(record.documentId);
} catch {
// File doesn't exist — delete the state
await prisma.docCollabState.delete({ where: { id: record.id } });
cleaned++;
}
}
if (cleaned > 0) {
logger.info(`Docs collab: cleaned up ${cleaned} stale collaboration states`);
}
} catch (err) {
logger.warn('Docs collab: cleanup failed', err);
}
}
/**
* Gracefully close all connections and stop Hocuspocus.
*/
async function shutdown(): Promise<void> {
hocuspocus.closeConnections();
collabConnections.set(0);
collabDocuments.set(0);
}
export const docsCollabService = {
handleConnection,
invalidateDocument,
cleanupStaleStates,
shutdown,
getConnectionsCount: () => hocuspocus.getConnectionsCount(),
getDocumentsCount: () => hocuspocus.getDocumentsCount(),
};

View File

@ -5,10 +5,12 @@ import { extname, basename } from 'path';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
import { env } from '../../config/env';
import { CONTENT_ROLES } from '../../utils/roles';
import { logger } from '../../utils/logger';
import { isServiceOnline } from '../../utils/health-check';
import { cm_docs_operations } from '../../utils/metrics';
import { docsFilesService, PathTraversalError, FileNotFoundError } from './docs-files.service';
import { docsCollabService } from './docs-collab.service';
import { mkdocsConfigService } from './mkdocs-config.service';
import { headerBuilderService } from './header-builder.service';
import { headerConfigSchema } from './header-builder.schemas';
@ -73,7 +75,7 @@ router.get(
// PUT /api/docs/mkdocs-config — validate + write mkdocs.yml (SUPER_ADMIN only)
router.put(
'/mkdocs-config',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { content } = req.body as { content?: string };
@ -94,10 +96,10 @@ router.put(
},
);
// POST /api/docs/build — trigger mkdocs build in container (SUPER_ADMIN only)
// POST /api/docs/build — trigger mkdocs build in container
router.post(
'/build',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (_req: Request, res: Response, next: NextFunction) => {
try {
const result = await mkdocsConfigService.triggerBuild();
@ -128,7 +130,7 @@ router.get(
// PUT /api/docs/header-config — save header nav bar config + regenerate template
router.put(
'/header-config',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsed = headerConfigSchema.safeParse(req.body);
@ -172,7 +174,7 @@ const upload = multer({
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
router.post(
'/upload',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
upload.single('file'),
async (req: Request, res: Response, next: NextFunction) => {
const tempPath = req.file?.path;
@ -244,7 +246,7 @@ router.get(
// POST /api/docs/files/rename — rename/move file
router.post(
'/files/rename',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'rename' });
@ -254,6 +256,8 @@ router.post(
return;
}
await docsFilesService.renameFile(from, to);
// Invalidate old path's collaboration state
docsCollabService.invalidateDocument(from).catch(() => {});
res.json({ success: true });
} catch (err) {
handleFileError(err, res, next);
@ -283,7 +287,7 @@ router.get(
// PUT /api/docs/files/* — write/update file content
router.put(
'/files/*',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'write' });
@ -298,6 +302,8 @@ router.put(
return;
}
await docsFilesService.writeFileContent(filePath, content);
// Invalidate collaboration state so next session starts fresh from disk
docsCollabService.invalidateDocument(filePath).catch(() => {});
res.json({ success: true, path: filePath });
} catch (err) {
handleFileError(err, res, next);
@ -308,7 +314,7 @@ router.put(
// POST /api/docs/files/* — create new file or folder
router.post(
'/files/*',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'create' });
@ -329,7 +335,7 @@ router.post(
// DELETE /api/docs/files/* — delete file or empty folder
router.delete(
'/files/*',
requireRole('SUPER_ADMIN'),
requireRole(...CONTENT_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
cm_docs_operations.inc({ operation: 'delete' });
@ -339,6 +345,8 @@ router.delete(
return;
}
await docsFilesService.deleteFile(filePath);
// Invalidate collaboration state for deleted file
docsCollabService.invalidateDocument(filePath).catch(() => {});
res.json({ success: true });
} catch (err) {
handleFileError(err, res, next);

View File

@ -15,6 +15,7 @@ import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { UserRole } from '@prisma/client';
import { Request, Response } from 'express';
import { BROADCAST_ROLES } from '../../utils/roles';
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
@ -24,8 +25,8 @@ const router = Router();
// All email template routes require authentication
router.use(authenticate);
// All routes require admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)
const requireAdminRole = requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN);
// All routes require broadcast admin role
const requireBroadcastRole = requireRole(...BROADCAST_ROLES);
/**
* List email templates
@ -33,7 +34,7 @@ const requireAdminRole = requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_AD
*/
router.get(
'/',
requireAdminRole,
requireBroadcastRole,
validate(listEmailTemplatesSchema, 'query'),
async (req: Request, res: Response): Promise<void> => {
try {
@ -52,7 +53,7 @@ router.get(
*/
router.get(
'/:id',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const template = await emailTemplatesService.getById(req.params.id as string);
@ -74,7 +75,7 @@ router.get(
*/
router.post(
'/',
requireAdminRole,
requireBroadcastRole,
validate(createEmailTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
@ -101,7 +102,7 @@ router.post(
*/
router.put(
'/:id',
requireAdminRole,
requireBroadcastRole,
validate(updateEmailTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
@ -133,7 +134,7 @@ router.put(
*/
router.delete(
'/:id',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
// Fetch template before deleting to get the key
@ -167,7 +168,7 @@ router.delete(
*/
router.get(
'/:id/versions',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const versions = await emailTemplatesService.getVersions(req.params.id as string);
@ -185,7 +186,7 @@ router.get(
*/
router.get(
'/:id/versions/:versionNumber',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const version = await emailTemplatesService.getVersion(
@ -210,7 +211,7 @@ router.get(
*/
router.post(
'/:id/rollback',
requireAdminRole,
requireBroadcastRole,
validate(rollbackToVersionSchema),
async (req: Request, res: Response): Promise<void> => {
try {
@ -237,7 +238,7 @@ router.post(
*/
router.post(
'/validate',
requireAdminRole,
requireBroadcastRole,
validate(validateTemplateSchema),
async (req: Request, res: Response): Promise<void> => {
try {
@ -257,7 +258,7 @@ router.post(
*/
router.post(
'/:id/test',
requireAdminRole,
requireBroadcastRole,
rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10,
@ -291,7 +292,7 @@ router.post(
*/
router.get(
'/:id/test-logs',
requireAdminRole,
requireBroadcastRole,
async (req: Request, res: Response): Promise<void> => {
try {
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;

View File

@ -1,15 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { galleryAdsService } from './gallery-ads.service';
import { createAdSchema, updateAdSchema, listAdsSchema, reorderAdsSchema, adAnalyticsQuerySchema } from './gallery-ads.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { PAYMENTS_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(UserRole.SUPER_ADMIN));
router.use(requireRole(...PAYMENTS_ROLES));
// GET /api/gallery-ads/admin — list all ads (paginated)
router.get(

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { campaignEmailsService } from './campaign-emails.service';
import {
sendCampaignEmailSchema,
@ -10,8 +9,7 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { emailRateLimit } from '../../../middleware/rate-limit';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
// --- Public Routes (no auth) ---
const publicRouter = Router();
@ -53,7 +51,7 @@ publicRouter.post(
// --- Admin Routes (auth required) ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...ADMIN_ROLES));
adminRouter.use(requireRole(...INFLUENCE_ROLES));
// GET /api/campaigns/:id/emails
adminRouter.get(

View File

@ -1,17 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { campaignsService } from './campaigns.service';
import { listModerationQueueSchema, moderateCampaignSchema } from './campaigns.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/campaigns/moderation/queue — list moderation queue
router.get(

View File

@ -1,18 +1,16 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { campaignsService } from './campaigns.service';
import { createCampaignSchema, updateCampaignSchema, listCampaignsSchema } from './campaigns.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
// All campaign admin routes require authentication + admin role
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/campaigns — list campaigns with pagination/filters
router.get(

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
@ -10,12 +9,11 @@ import {
geoQuerySchema,
repQuerySchema,
} from './effectiveness.schemas';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/influence/effectiveness/overview
router.get(

View File

@ -1,14 +1,12 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { emailQueueService } from '../../../services/email-queue.service';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/email-queue/stats
router.get(

View File

@ -1,13 +1,11 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { representativesService } from './representatives.service';
import { listRepresentativesSchema } from './representatives.schemas';
import { postalCodeParamSchema, postalCodeQuerySchema } from '../postal-codes/postal-codes.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
const router = Router();
@ -50,7 +48,7 @@ router.get(
// =============================================
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...INFLUENCE_ROLES));
// GET /api/representatives/cache-stats — cache statistics
router.get(

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { responsesService } from './responses.service';
import {
submitResponseSchema,
@ -12,8 +11,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
import { optionalAuth } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { responseRateLimit } from '../../../middleware/rate-limit';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { INFLUENCE_ROLES } from '../../../utils/roles';
// --- Campaign-scoped public routes (mount at /api/campaigns) ---
const campaignPublicRouter = Router();
@ -144,7 +142,7 @@ responsesPublicRouter.get(
// --- Admin routes (mount at /api/responses) ---
const responsesAdminRouter = Router();
responsesAdminRouter.use(authenticate);
responsesAdminRouter.use(requireRole(...ADMIN_ROLES));
responsesAdminRouter.use(requireRole(...INFLUENCE_ROLES));
// GET /api/responses
responsesAdminRouter.get(

View File

@ -1,15 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { listmonkClient } from '../../services/listmonk.client';
import { listmonkSyncService } from '../../services/listmonk-sync.service';
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
import { env } from '../../config/env';
import { BROADCAST_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(UserRole.SUPER_ADMIN));
router.use(requireRole(...BROADCAST_ROLES));
// GET /api/listmonk — sync status
router.get(

View File

@ -8,10 +8,11 @@ import {
exportContactsToCampaign,
getCutCampaignAnalytics,
} from './canvass-export.service';
import { MAP_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN', 'INFLUENCE_ADMIN'));
router.use(requireRole(...MAP_ROLES));
// POST /api/map/canvass/export-contacts/preview — preview matching contacts
router.post(

View File

@ -20,8 +20,7 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
// ─── Volunteer Router ────────────────────────────────────────────────
const volunteerRouter = Router();
@ -282,7 +281,7 @@ volunteerRouter.post(
// ─── Admin Router ────────────────────────────────────────────────────
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/canvass/stats
adminRouter.get(

View File

@ -1,11 +1,11 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import multer from 'multer';
import { cutsService } from './cuts.service';
import { createCutSchema, updateCutSchema, listCutsSchema } from './cuts.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { MAP_ROLES } from '../../../utils/roles';
const geojsonUpload = multer({
storage: multer.memoryStorage(),
@ -19,12 +19,10 @@ const geojsonUpload = multer({
},
});
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/cuts — list paginated
adminRouter.get(

View File

@ -1,12 +1,10 @@
import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod';
import { UserRole } from '@prisma/client';
import { geocodingService } from './geocoding.service';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const searchSchema = z.object({
q: z.string().min(2, 'Query must be at least 2 characters'),
@ -15,7 +13,7 @@ const searchSchema = z.object({
const router = Router();
router.use(authenticate);
router.use(requireRole(...MAP_ADMIN_ROLES));
router.use(requireRole(...MAP_ROLES));
// GET /api/map/geocoding/search?q=Ottawa&limit=5
router.get(

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { randomUUID } from 'crypto';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
@ -8,12 +7,11 @@ import { areaImportPreviewSchema, areaImportStartSchema } from './area-import.sc
import { areaImportService, type AreaImportProgress } from './area-import.service';
import { redis } from '../../../config/redis';
import { logger } from '../../../utils/logger';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const areaImportRouter = Router();
areaImportRouter.use(authenticate);
areaImportRouter.use(requireRole(...MAP_ADMIN_ROLES));
areaImportRouter.use(requireRole(...MAP_ROLES));
// POST /api/map/area-import/preview — get bounds, estimates, and existing count
areaImportRouter.post(

View File

@ -1,16 +1,14 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { geocodeQueueService } from '../../../services/geocode-queue.service';
import { bulkGeocodeSchema } from './bulk-geocode.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...MAP_ADMIN_ROLES));
router.use(requireRole(...MAP_ROLES));
// POST /api/map/locations/bulk-geocode — start bulk geocoding job
router.post(

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import multer from 'multer';
import { locationsService } from './locations.service';
import {
@ -17,8 +16,7 @@ import { prisma } from '../../../config/database';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
// Multer config for CSV upload (memory storage, 10MB limit)
const upload = multer({
@ -49,7 +47,7 @@ const bulkUpload = multer({
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/locations — list with pagination + filters
adminRouter.get(

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { z } from 'zod';
import { randomUUID } from 'crypto';
import { narImportService, writeProgress } from './nar-import.service';
@ -8,8 +7,7 @@ import { logger } from '../../../utils/logger';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const serverImportSchema = z.object({
provinceCode: z.string().min(1).max(2),
@ -24,7 +22,7 @@ const serverImportSchema = z.object({
const narImportRouter = Router();
narImportRouter.use(authenticate);
narImportRouter.use(requireRole(...MAP_ADMIN_ROLES));
narImportRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/nar-import/datasets — list available NAR datasets by province
narImportRouter.get(

View File

@ -1,12 +1,10 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { mapSettingsService } from './settings.service';
import { updateMapSettingsSchema } from './settings.schemas';
import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
const router = Router();
@ -27,7 +25,7 @@ router.get(
router.put(
'/',
authenticate,
requireRole(...MAP_ADMIN_ROLES),
requireRole(...MAP_ROLES),
validate(updateMapSettingsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {

View File

@ -2,14 +2,14 @@ import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { UserRole } from '@prisma/client';
import { ShiftSeriesService } from './shift-series.service';
import { createShiftSeriesSchema, updateShiftSeriesSchema } from './shift-series.schemas';
import { SCHEDULING_ROLES } from '../../../utils/roles';
const router = Router();
// All routes require admin role
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN));
router.use(authenticate, requireRole(...SCHEDULING_ROLES));
// Create series
router.post(

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { shiftsService } from './shifts.service';
import {
createShiftSchema,
@ -14,13 +13,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
import { shiftSignupRateLimit } from '../../../middleware/rate-limit';
import { prisma } from '../../../config/database';
import { redis } from '../../../config/redis';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { SCHEDULING_ROLES } from '../../../utils/roles';
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...SCHEDULING_ROLES));
// GET /api/map/shifts — list paginated
adminRouter.get(

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { trackingService } from './tracking.service';
import {
startTrackingSchema,
@ -13,8 +12,7 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { gpsTrackingRateLimit } from '../../../middleware/rate-limit';
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
import { MAP_ROLES } from '../../../utils/roles';
// ─── Volunteer Router ────────────────────────────────────────────────
const volunteerRouter = Router();
@ -135,7 +133,7 @@ volunteerRouter.get(
// ─── Admin Router ────────────────────────────────────────────────────
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
adminRouter.use(requireRole(...MAP_ROLES));
// GET /api/map/tracking/live — active volunteers with positions + recent trails
adminRouter.get(

View File

@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { hasAnyRole, ADMIN_ROLES as ADMIN_ROLE_LIST, getUserRoles } from '../../../utils/roles';
import { hasAnyRole, MEDIA_ROLES, getUserRoles } from '../../../utils/roles';
// Extend FastifyRequest to include user
declare module 'fastify' {
@ -123,7 +123,7 @@ export async function requireAdminRole(
}
// Check admin role using multi-role utility
if (!request.user || !hasAnyRole(request.user, ADMIN_ROLE_LIST)) {
if (!request.user || !hasAnyRole(request.user, MEDIA_ROLES)) {
return reply.status(403).send({
error: 'Admin access required',
code: 'ADMIN_REQUIRED'

View File

@ -5,7 +5,7 @@ import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { requireAdminRole } from '../middleware/auth';
import { logger } from '../../../utils/logger';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
import { unlink } from 'fs/promises';
/**
@ -32,7 +32,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
roles?: UserRole[];
};
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
const user = await prisma.user.findUnique({
where: { id: payload.id },

View File

@ -8,7 +8,7 @@ import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../../../config/database';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
/**
* Check if the request is from an authenticated admin user.
@ -37,7 +37,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
};
// Check admin role from token (multi-role aware)
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
// Verify user is still active in DB
const user = await prisma.user.findUnique({

View File

@ -1,5 +1,4 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { meetingPlannerService } from './meeting-planner.service';
import {
createPollSchema,
@ -13,17 +12,16 @@ import {
listPollsSchema,
} from './meeting-planner.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { authenticate, optionalAuth } 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];
import { EVENTS_ROLES } from '../../utils/roles';
// --- Admin Router ---
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...ADMIN_ROLES));
adminRouter.use(requireRole(...EVENTS_ROLES));
// List polls
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
@ -151,7 +149,7 @@ const publicRouter = Router();
// Public listing of open polls
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await meetingPlannerService.findAll({
const result = await meetingPlannerService.findAllPublic({
status: 'OPEN',
limit: 50,
page: 1,
@ -161,51 +159,28 @@ publicRouter.get('/public', async (req: Request, res: Response, next: NextFuncti
});
// View poll by slug
publicRouter.get('/public/:slug', async (req: Request, res: Response, next: NextFunction) => {
publicRouter.get('/public/:slug', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const poll = await meetingPlannerService.findBySlug(slug);
const poll = await meetingPlannerService.findBySlugPublic(slug, req.user?.id);
res.json(poll);
} catch (err) { next(err); }
});
// Submit votes
publicRouter.post('/public/:slug/vote', pollVoteRateLimit, validate(submitVotesSchema), async (req: Request, res: Response, next: NextFunction) => {
publicRouter.post('/public/:slug/vote', optionalAuth, 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);
const result = await meetingPlannerService.submitVotes(slug, req.body, req.user?.id);
res.json(result);
} catch (err) { next(err); }
});
// Add comment
publicRouter.post('/public/:slug/comment', pollCommentRateLimit, validate(submitCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
publicRouter.post('/public/:slug/comment', optionalAuth, 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);
const comment = await meetingPlannerService.addComment(slug, req.body, req.user?.id);
res.status(201).json(comment);
} catch (err) { next(err); }
});

View File

@ -7,6 +7,7 @@ export const createPollSchema = z.object({
location: z.string().max(500).optional(),
timezone: z.string().default('America/Edmonton'),
allowAnonymous: z.boolean().optional().default(true),
isPrivate: z.boolean().optional().default(false),
notifyOnVote: z.boolean().optional().default(true),
votingDeadline: z.string().datetime().optional(),
options: z.array(z.object({
@ -22,6 +23,7 @@ export const updatePollSchema = z.object({
location: z.string().max(500).nullable().optional(),
timezone: z.string().optional(),
allowAnonymous: z.boolean().optional(),
isPrivate: z.boolean().optional(),
notifyOnVote: z.boolean().optional(),
votingDeadline: z.string().datetime().nullable().optional(),
status: z.nativeEnum(SchedulingPollStatus).optional(),

View File

@ -1,3 +1,4 @@
import crypto from 'crypto';
import { Prisma, PollVoteValue } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
@ -22,6 +23,7 @@ const pollInclude = {
_count: { select: { options: true, votes: true, comments: true } },
} as const;
// Admin detail include — returns all vote fields (for admin endpoints)
const pollDetailInclude = {
options: {
orderBy: { sortOrder: 'asc' as const },
@ -34,6 +36,31 @@ const pollDetailInclude = {
_count: { select: { options: true, votes: true, comments: true } },
} as const;
// Public detail include — strips voterEmail and voterToken from votes
const pollDetailPublicInclude = {
options: {
orderBy: { sortOrder: 'asc' as const },
include: {
votes: {
orderBy: { createdAt: 'asc' as const },
select: {
id: true,
pollId: true,
optionId: true,
voterName: true,
userId: true,
value: true,
createdAt: true,
// voterEmail and voterToken intentionally excluded
},
},
},
},
comments: { orderBy: { createdAt: 'asc' as const } },
createdBy: { select: { id: true, name: true } }, // exclude email from public
_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;
@ -56,7 +83,7 @@ function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollV
function groupVotesByVoter(votes: Array<{
voterName: string;
voterToken: string | null;
voterToken?: string | null;
userId: string | null;
optionId: string;
value: PollVoteValue;
@ -139,6 +166,68 @@ export const meetingPlannerService = {
return { ...poll, options: optionsWithCounts, voters };
},
async findBySlugPublic(slug: string, userId?: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { slug },
include: pollDetailPublicInclude,
});
if (!poll) throw new AppError(404, 'Poll not found');
// If private and not authenticated, return limited data
if (poll.isPrivate && !userId) {
return {
id: poll.id,
slug: poll.slug,
title: poll.title,
description: poll.description,
location: poll.location,
status: poll.status,
timezone: poll.timezone,
allowAnonymous: poll.allowAnonymous,
isPrivate: poll.isPrivate,
notifyOnVote: poll.notifyOnVote,
createdBy: poll.createdBy,
createdByUserId: poll.createdByUserId,
createdAt: poll.createdAt,
updatedAt: poll.updatedAt,
votingDeadline: poll.votingDeadline,
finalizedOptionId: null,
finalizedOption: null,
convertedShiftId: null,
convertedGancioEventId: null,
requiresAuth: true,
options: [],
voters: [],
comments: [],
_count: { options: 0, votes: 0, comments: 0 },
};
}
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, requiresAuth: false };
},
async findAllPublic(filters: ListPollsInput) {
const result = await this.findAll({ ...filters, status: 'OPEN' });
return {
...result,
// Filter out private polls entirely from the public listing
polls: result.polls
.filter((poll) => !poll.isPrivate)
.map((poll) => ({
...poll,
requiresAuth: false,
// Strip organizer email from public listing
createdBy: poll.createdBy ? { id: poll.createdBy.id, name: poll.createdBy.name } : null,
})),
};
},
async create(data: CreatePollInput, userId: string) {
const slug = generateSlug(data.title);
@ -150,6 +239,7 @@ export const meetingPlannerService = {
location: data.location,
timezone: data.timezone,
allowAnonymous: data.allowAnonymous,
isPrivate: data.isPrivate,
notifyOnVote: data.notifyOnVote,
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
createdByUserId: userId,
@ -178,6 +268,7 @@ export const meetingPlannerService = {
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.isPrivate !== undefined) updateData.isPrivate = data.isPrivate;
if (data.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
if (data.votingDeadline !== undefined) {
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
@ -263,6 +354,9 @@ export const meetingPlannerService = {
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
throw new AppError(400, 'The voting deadline has passed');
}
if (poll.isPrivate && !userId) {
throw new AppError(401, 'This poll requires authentication to vote');
}
if (!poll.allowAnonymous && !userId) {
throw new AppError(401, 'This poll requires authentication to vote');
}
@ -332,6 +426,12 @@ export const meetingPlannerService = {
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');
if (poll.isPrivate && !userId) {
throw new AppError(401, 'This poll requires authentication to comment');
}
if (!poll.allowAnonymous && !userId) {
throw new AppError(401, 'This poll requires authentication to comment');
}
return prisma.schedulingPollComment.create({
data: {
@ -517,12 +617,7 @@ export const meetingPlannerService = {
};
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;
return crypto.randomBytes(18).toString('base64url').slice(0, 24);
}
function escapeHtml(str: string): string {

View File

@ -1,17 +1,15 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { blocksService } from './blocks.service';
import { createPageBlockSchema, updatePageBlockSchema, listPageBlocksSchema } from './pages.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { CONTENT_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...CONTENT_ROLES));
// GET /api/page-blocks — list all blocks
router.get(

View File

@ -1,18 +1,16 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { pagesService } from './pages.service';
import { createLandingPageSchema, updateLandingPageSchema, listLandingPagesSchema } from './pages.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { prisma } from '../../config/database';
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
import { CONTENT_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...ADMIN_ROLES));
router.use(requireRole(...CONTENT_ROLES));
// GET /api/pages/view-counts — landing page view counts (last 30d)
router.get(

View File

@ -1,7 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { PAYMENTS_ROLES } from '../../utils/roles';
import { validate } from '../../middleware/validate';
import { donationPagesService } from './donation-pages.service';
import {
@ -12,8 +12,8 @@ import {
const router = Router();
// All routes require SUPER_ADMIN
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
// All routes require PAYMENTS_ROLES
router.use(authenticate, requireRole(...PAYMENTS_ROLES));
// GET /api/payments/admin/donation-pages — list with pagination, search, status
router.get(

View File

@ -1,7 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express';
import { UserRole } from '@prisma/client';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { PAYMENTS_ROLES } from '../../utils/roles';
import { validate } from '../../middleware/validate';
import { paymentSettingsService } from './payment-settings.service';
import { subscriptionsService } from './subscriptions.service';
@ -22,8 +22,8 @@ import {
const router = Router();
// All admin routes require SUPER_ADMIN
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
// All admin routes require PAYMENTS_ROLES
router.use(authenticate, requireRole(...PAYMENTS_ROLES));
// =================== Settings ===================

View File

@ -8,6 +8,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
import { emailService } from '../../services/email.service';
import { giteaClient } from '../../services/gitea.client';
import { gancioSettingsSyncService } from '../../services/gancio-settings-sync.service';
import { autoUpgradeService } from '../../services/auto-upgrade.service';
import { headerBuilderService } from '../docs/header-builder.service';
import { mkdocsConfigService } from '../docs/mkdocs-config.service';
import { logger } from '../../utils/logger';
@ -35,6 +36,7 @@ router.get(
async (_req: Request, res: Response, next: NextFunction) => {
try {
const settings = await siteSettingsService.getEffective();
res.set('Cache-Control', 'no-store');
res.json(settings);
} catch (err) {
next(err);
@ -115,6 +117,12 @@ router.put(
gancioSettingsSyncService.syncChanged(req.body).catch(() => {});
}
// If auto-upgrade settings changed, restart the scheduler
const autoUpgradeFields = ['enableAutoUpgrade', 'autoUpgradeSchedule', 'autoUpgradePullServices'];
if (autoUpgradeFields.some((f) => f in req.body)) {
autoUpgradeService.start().catch(() => {});
}
// If navConfig or theme colors changed, trigger MkDocs header rebuild + docs build
const headerTriggerFields = [
'navConfig', 'publicHeaderGradient', 'publicColorBgBase', 'publicColorBgContainer',

View File

@ -59,6 +59,7 @@ export const updateSiteSettingsSchema = z.object({
enableMeetingPlanner: z.boolean().optional(),
enableTicketedEvents: z.boolean().optional(),
enableSocialCalendar: z.boolean().optional(),
enableDocsCollaboration: z.boolean().optional(),
requireEventApproval: z.boolean().optional(),
autoSyncPeopleToMap: z.boolean().optional(),
@ -86,6 +87,15 @@ export const updateSiteSettingsSchema = z.object({
provisionListmonk: z.boolean().optional(),
provisionListmonkTiming: z.enum(['lazy', 'eager']).optional(),
// Auto-upgrade settings
enableAutoUpgrade: z.boolean().optional(),
autoUpgradeSchedule: z.enum([
'daily-3am', 'daily-4am', 'daily-5am',
'weekly-sun-3am', 'weekly-mon-3am', '12h', '24h',
]).optional(),
autoUpgradePullServices: z.boolean().optional(),
notifyAdminAutoUpgrade: z.boolean().optional(),
// Navigation configuration (supports one level of nesting via groups)
navConfig: z.object({
items: z.array(z.object({

View File

@ -5,11 +5,12 @@ import { validate } from '../../../middleware/validate';
import { smsCampaignsService } from './sms-campaigns.service';
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
import { smsQueueService } from '../../../services/sms-queue.service';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// All routes require authentication + broadcast admin role
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/campaigns — list all campaigns
router.get('/', async (req, res, next) => {

View File

@ -4,11 +4,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsContactsService } from './sms-contacts.service';
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// All routes require authentication + broadcast admin role
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// --- Contact Lists ---

View File

@ -2,10 +2,11 @@ import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsConversationsService } from './sms-conversations.service';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/conversations — list conversations
router.get('/', async (req, res, next) => {

View File

@ -3,10 +3,11 @@ import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsDeviceService } from './sms-device.service';
import { termuxClient } from '../../../services/termux.client';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/device — latest device status
router.get('/', async (_req, res, next) => {

View File

@ -2,10 +2,11 @@ import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { smsMessagesService } from './sms-messages.service';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/messages — list all messages
router.get('/', async (req, res, next) => {

View File

@ -4,11 +4,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { smsTemplatesService } from './sms-templates.service';
import { createSmsTemplateSchema, updateSmsTemplateSchema } from './sms-templates.schemas';
import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router();
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
// All routes require authentication + broadcast admin role
router.use(authenticate, requireRole(...BROADCAST_ROLES));
// GET /api/sms/templates — list with search/filter/pagination
router.get('/', async (req, res, next) => {

View File

@ -1,6 +1,7 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { challengeService } from './challenge.service';
import {
createChallengeSchema,
@ -98,7 +99,7 @@ router.get('/:id/teams/:teamId', async (req: Request, res: Response) => {
// ── Admin ────────────────────────────────────────────────────────────
const adminRouter = Router();
adminRouter.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
adminRouter.use(requireRole(...SOCIAL_ROLES));
/** POST /admin — create challenge */
adminRouter.post('/', async (req: Request, res: Response) => {

View File

@ -1,5 +1,6 @@
import { Router } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { INFLUENCE_ROLES } from '../../utils/roles';
import { impactStoriesService } from './impact-stories.service';
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
@ -7,7 +8,7 @@ const router = Router();
// --- Admin routes (require admin role) ---
router.post('/', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.post('/', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const data = createStorySchema.parse(req.body);
const story = await impactStoriesService.create(data, req.user!.id);
@ -17,7 +18,7 @@ router.post('/', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), asy
}
});
router.put('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.put('/:id', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const data = updateStorySchema.parse(req.body);
const story = await impactStoriesService.update(req.params.id as string, data);
@ -27,7 +28,7 @@ router.put('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), a
}
});
router.delete('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.delete('/:id', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const result = await impactStoriesService.delete(req.params.id as string);
res.json(result);
@ -36,7 +37,7 @@ router.delete('/:id', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN')
}
});
router.post('/:id/publish', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.post('/:id/publish', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const story = await impactStoriesService.publish(req.params.id as string);
// Fire-and-forget: notify participants
@ -47,7 +48,7 @@ router.post('/:id/publish', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_A
}
});
router.post('/:id/archive', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req, res, next) => {
router.post('/:id/archive', requireRole(...INFLUENCE_ROLES), async (req, res, next) => {
try {
const story = await impactStoriesService.archive(req.params.id as string);
res.json(story);
@ -64,7 +65,7 @@ router.get('/', async (req, res, next) => {
// Admin users can filter by status; regular users see published only
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some((r: string) =>
['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'].includes(r),
(INFLUENCE_ROLES as string[]).includes(r),
);
if (isAdmin && (campaignId || status)) {

View File

@ -1,6 +1,7 @@
import { Router } from 'express';
import type { Request, Response } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { referralService } from './referral.service';
import { createInviteCodeSchema, validateCodeSchema, paginationSchema } from './referral.schemas';
@ -72,7 +73,7 @@ router.get('/stats', async (req: Request, res: Response) => {
});
/** GET /api/social/referrals/admin/all — all referrals (admin only) */
router.get('/admin/all', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
router.get('/admin/all', requireRole(...SOCIAL_ROLES), async (req: Request, res: Response) => {
try {
const { page, limit } = paginationSchema.parse(req.query);
const result = await referralService.listAllReferrals(page, limit);
@ -83,7 +84,7 @@ router.get('/admin/all', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMI
});
/** GET /api/social/referrals/admin/leaderboard — top referrers (admin only) */
router.get('/admin/leaderboard', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), async (req: Request, res: Response) => {
router.get('/admin/leaderboard', requireRole(...SOCIAL_ROLES), async (req: Request, res: Response) => {
try {
const limit = parseInt((req.query.limit as string) || '10', 10);
const leaderboard = await referralService.getReferralLeaderboard(Math.min(limit, 50));

View File

@ -1,6 +1,7 @@
import { Router } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { friendshipRouter } from './friendship.routes';
import { blockRouter } from './block.routes';
import { privacyRouter } from './privacy.routes';
@ -35,7 +36,7 @@ router.use((req, _res, next) => {
router.use(authenticate);
// Admin sub-router (requires admin role)
router.use('/admin', requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'), socialAdminRouter);
router.use('/admin', requireRole(...SOCIAL_ROLES), socialAdminRouter);
// Sub-routers
router.use('/friends', friendshipRouter);

View File

@ -1,6 +1,7 @@
import { Router } from 'express';
import type { Request, Response, NextFunction } from 'express';
import { requireRole } from '../../middleware/rbac.middleware';
import { SOCIAL_ROLES } from '../../utils/roles';
import { spotlightService } from './spotlight.service';
import {
nominateSchema,
@ -88,7 +89,7 @@ router.post('/opt-out', async (req: Request, res: Response, next: NextFunction)
/** GET /api/social/spotlight/admin — list all spotlights */
router.get(
'/admin',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { page, limit, status } = listSpotlightsSchema.parse(req.query);
@ -103,7 +104,7 @@ router.get(
/** POST /api/social/spotlight/admin/nominate — nominate a volunteer */
router.post(
'/admin/nominate',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = nominateSchema.parse(req.body);
@ -118,7 +119,7 @@ router.post(
/** PUT /api/social/spotlight/admin/:id — update headline/story */
router.put(
'/admin/:id',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const data = updateSpotlightSchema.parse(req.body);
@ -133,7 +134,7 @@ router.put(
/** POST /api/social/spotlight/admin/:id/approve — approve a nomination */
router.post(
'/admin/:id/approve',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const spotlight = await spotlightService.approve(req.params.id as string, req.user!.id);
@ -147,7 +148,7 @@ router.post(
/** POST /api/social/spotlight/admin/:id/feature — feature for a month */
router.post(
'/admin/:id/feature',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const { month } = featureSchema.parse(req.body);
@ -162,7 +163,7 @@ router.post(
/** POST /api/social/spotlight/admin/:id/archive — archive a spotlight */
router.post(
'/admin/:id/archive',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const spotlight = await spotlightService.archive(req.params.id as string);
@ -176,7 +177,7 @@ router.post(
/** DELETE /api/social/spotlight/admin/:id — delete a spotlight */
router.delete(
'/admin/:id',
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
requireRole(...SOCIAL_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await spotlightService.delete(req.params.id as string);

View File

@ -13,16 +13,16 @@ import {
} from './ticketed-events.schemas';
import { prisma } from '../../config/database';
import { UserRole } from '@prisma/client';
import { EVENTS_ROLES } from '../../utils/roles';
const router = Router();
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
/** Middleware: require admin role OR canCreateTicketedEvents permission */
async function requireEventPermission(req: Request, _res: Response, next: NextFunction) {
if (!req.user) return next(new Error('Auth required'));
const userRoles = req.user.roles || [req.user.role];
if (userRoles.some(r => ADMIN_ROLES.includes(r as UserRole))) {
if (userRoles.some(r => EVENTS_ROLES.includes(r as UserRole))) {
return next();
}
@ -51,7 +51,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
const search = req.query.search as string | undefined;
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some(r => ADMIN_ROLES.includes(r as UserRole));
const isAdmin = userRoles.some(r => EVENTS_ROLES.includes(r as UserRole));
const result = await ticketedEventsService.list({
page,
@ -110,7 +110,7 @@ router.post('/:id/publish', async (req: Request, res: Response, next: NextFuncti
});
// POST /:id/approve (admin only)
router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/approve', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.approve(req.params.id as string);
res.json(event);
@ -118,7 +118,7 @@ router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, re
});
// POST /:id/reject (admin only)
router.post('/:id/reject', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/reject', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.reject(req.params.id as string);
res.json(event);
@ -134,7 +134,7 @@ router.post('/:id/cancel', async (req: Request, res: Response, next: NextFunctio
});
// POST /:id/complete (admin only)
router.post('/:id/complete', requireRole(...ADMIN_ROLES), async (req: Request, res: Response, next: NextFunction) => {
router.post('/:id/complete', requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const event = await ticketedEventsService.complete(req.params.id as string);
res.json(event);

View File

@ -57,6 +57,15 @@ router.post('/start', (req, res) => {
}
});
/**
* GET /api/upgrade/history
* Returns the history of past upgrade results (newest first).
*/
router.get('/history', (_req, res) => {
const history = upgradeService.getHistory();
res.json({ history });
});
/**
* POST /api/upgrade/clear-result
* Removes the last upgrade result file.

View File

@ -14,11 +14,14 @@ const STATUS_FILE = path.join(UPGRADE_DIR, 'status.json');
const PROGRESS_FILE = path.join(UPGRADE_DIR, 'progress.json');
const RESULT_FILE = path.join(UPGRADE_DIR, 'result.json');
const TRIGGER_FILE = path.join(UPGRADE_DIR, 'trigger.json');
const HISTORY_FILE = path.join(UPGRADE_DIR, 'history.json');
const TRIGGERED_BY_FILE = path.join(UPGRADE_DIR, 'triggered-by.txt');
// Stale threshold: if progress hasn't been updated in this many ms, assume crashed
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
const MAX_HISTORY_ENTRIES = 50;
interface UpgradeStatus {
export interface UpgradeStatus {
branch: string;
currentCommit: string;
currentCommitFull: string;
@ -37,7 +40,7 @@ interface UpgradeStatus {
error: string | null;
}
interface UpgradeProgress {
export interface UpgradeProgress {
phase: number;
phaseName: string;
percentage: number;
@ -45,7 +48,7 @@ interface UpgradeProgress {
lastUpdate: string;
}
interface UpgradeResult {
export interface UpgradeResult {
success: boolean;
message: string;
previousCommit: string;
@ -54,6 +57,7 @@ interface UpgradeResult {
durationSeconds: number;
warnings: string[];
completedAt: string;
triggeredBy?: string;
}
interface TriggerPayload {
@ -170,6 +174,48 @@ function clearStaleProgress(): void {
}
}
/** Archive a completed upgrade result to the persistent history file. */
function archiveResult(result: UpgradeResult): void {
try {
const history = readJsonFile<UpgradeResult[]>(HISTORY_FILE) || [];
history.unshift(result);
// Trim to max entries
if (history.length > MAX_HISTORY_ENTRIES) {
history.length = MAX_HISTORY_ENTRIES;
}
writeJsonFile(HISTORY_FILE, history);
logger.info(`Archived upgrade result to history (${history.length} entries)`);
} catch (err) {
logger.warn('Failed to archive upgrade result:', err);
}
}
/** Get the list of past upgrade results (newest first). */
function getHistory(): UpgradeResult[] {
return readJsonFile<UpgradeResult[]>(HISTORY_FILE) || [];
}
/** Read the triggered-by marker file (written by upgrade-watcher.sh). */
function getTriggeredBy(): string | null {
try {
if (!fs.existsSync(TRIGGERED_BY_FILE)) return null;
return fs.readFileSync(TRIGGERED_BY_FILE, 'utf-8').trim() || null;
} catch {
return null;
}
}
/** Remove the triggered-by marker file. */
function clearTriggeredBy(): void {
try {
if (fs.existsSync(TRIGGERED_BY_FILE)) {
fs.unlinkSync(TRIGGERED_BY_FILE);
}
} catch {
// Ignore
}
}
export const upgradeService = {
getStatus,
getProgress,
@ -179,4 +225,8 @@ export const upgradeService = {
triggerUpgrade,
clearResult,
clearStaleProgress,
archiveResult,
getHistory,
getTriggeredBy,
clearTriggeredBy,
};

Some files were not shown because too many files have changed in this diff Show More