Tonne of things
This commit is contained in:
parent
3f35e4b18d
commit
76b87d9f3d
@ -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)
|
||||
|
||||
@ -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
132
admin/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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>
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
192
admin/src/components/calendar/CalendarItemDetail.tsx
Normal file
192
admin/src/components/calendar/CalendarItemDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
525
admin/src/components/calendar/CalendarTimeGrid.tsx
Normal file
525
admin/src/components/calendar/CalendarTimeGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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})`;
|
||||
}
|
||||
@ -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})`;
|
||||
}
|
||||
|
||||
172
admin/src/components/calendar/calendarUtils.ts
Normal file
172
admin/src/components/calendar/calendarUtils.ts
Normal 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}`;
|
||||
}
|
||||
@ -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',
|
||||
|
||||
@ -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 ?? [],
|
||||
},
|
||||
{
|
||||
|
||||
66
admin/src/components/docs/CollaboratorAvatars.tsx
Normal file
66
admin/src/components/docs/CollaboratorAvatars.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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',
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
156
admin/src/hooks/useDocsCollaboration.ts
Normal file
156
admin/src/hooks/useDocsCollaboration.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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' },
|
||||
|
||||
90
admin/src/lib/y-textarea.ts
Normal file
90
admin/src/lib/y-textarea.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 && (
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
197
api/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "scheduling_polls" ADD COLUMN "is_private" BOOLEAN NOT NULL DEFAULT false;
|
||||
@ -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;
|
||||
@ -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';
|
||||
@ -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';
|
||||
@ -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");
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
351
api/src/modules/docs/docs-collab.service.ts
Normal file
351
api/src/modules/docs/docs-collab.service.ts
Normal 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(),
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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); }
|
||||
});
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 ===================
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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 ---
|
||||
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user