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) ---
|
# --- Monitoring (only used with --profile monitoring) ---
|
||||||
PROMETHEUS_PORT=9090
|
PROMETHEUS_PORT=9090
|
||||||
GRAFANA_PORT=3005
|
GRAFANA_PORT=3005
|
||||||
GRAFANA_ADMIN_PASSWORD=admin
|
GRAFANA_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
GRAFANA_ROOT_URL=http://localhost:3005
|
GRAFANA_ROOT_URL=http://localhost:3005
|
||||||
CADVISOR_PORT=8086
|
CADVISOR_PORT=8086
|
||||||
NODE_EXPORTER_PORT=9100
|
NODE_EXPORTER_PORT=9100
|
||||||
@ -351,7 +351,7 @@ REDIS_EXPORTER_PORT=9121
|
|||||||
ALERTMANAGER_PORT=9093
|
ALERTMANAGER_PORT=9093
|
||||||
GOTIFY_PORT=8889
|
GOTIFY_PORT=8889
|
||||||
GOTIFY_ADMIN_USER=admin
|
GOTIFY_ADMIN_USER=admin
|
||||||
GOTIFY_ADMIN_PASSWORD=admin
|
GOTIFY_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||||
|
|
||||||
# --- Bunker Ops (Fleet Management) ---
|
# --- Bunker Ops (Fleet Management) ---
|
||||||
INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN)
|
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)
|
- **JWT-based auth:** access tokens (15min) + refresh tokens (7 days, stored in DB)
|
||||||
- **Password policy:** 12+ characters, uppercase, lowercase, digit (enforced at schema level)
|
- **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)
|
- **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`
|
- **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
|
- **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:**
|
- **Security features:**
|
||||||
- Refresh token rotation (atomic transaction)
|
- Refresh token rotation (atomic transaction)
|
||||||
- User enumeration prevention (401 not 404)
|
- 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/icons": "^5.6.0",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
|
"@hocuspocus/provider": "^3.4.4",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
@ -42,7 +43,9 @@
|
|||||||
"react-leaflet-cluster": "^4.0.0",
|
"react-leaflet-cluster": "^4.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
"y-monaco": "^0.1.6",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
"yjs": "^13.6.29",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -868,6 +871,29 @@
|
|||||||
"node": ">=18"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
@ -913,6 +939,11 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@monaco-editor/loader": {
|
||||||
"version": "1.7.0",
|
"version": "1.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
|
||||||
@ -2629,6 +2660,15 @@
|
|||||||
"node": ">=12"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@ -2690,6 +2730,26 @@
|
|||||||
"leaflet": "^1.3.1"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"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": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
@ -3891,6 +4007,22 @@
|
|||||||
"url": "https://github.com/sponsors/eemeli"
|
"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": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.11",
|
"version": "5.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"@ant-design/icons": "^5.6.0",
|
"@ant-design/icons": "^5.6.0",
|
||||||
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
"@ant-design/v5-patch-for-react-19": "^1.0.3",
|
||||||
"@dagrejs/dagre": "^2.0.4",
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
|
"@hocuspocus/provider": "^3.4.4",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@types/d3-force": "^3.0.10",
|
"@types/d3-force": "^3.0.10",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
@ -43,7 +44,9 @@
|
|||||||
"react-leaflet-cluster": "^4.0.0",
|
"react-leaflet-cluster": "^4.0.0",
|
||||||
"react-router-dom": "^7.1.1",
|
"react-router-dom": "^7.1.1",
|
||||||
"recharts": "^3.7.0",
|
"recharts": "^3.7.0",
|
||||||
|
"y-monaco": "^0.1.6",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
"yjs": "^13.6.29",
|
||||||
"zustand": "^5.0.3"
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -98,7 +98,19 @@ import SocialFeedPage from '@/pages/volunteer/SocialFeedPage';
|
|||||||
import DiscoverPage from '@/pages/volunteer/DiscoverPage';
|
import DiscoverPage from '@/pages/volunteer/DiscoverPage';
|
||||||
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
|
import GroupDetailPage from '@/pages/volunteer/GroupDetailPage';
|
||||||
import AchievementsPage from '@/pages/volunteer/AchievementsPage';
|
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 { isAdmin } from '@/utils/roles';
|
||||||
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
||||||
import VerifyEmailPage from '@/pages/VerifyEmailPage';
|
import VerifyEmailPage from '@/pages/VerifyEmailPage';
|
||||||
@ -128,7 +140,6 @@ import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
|
|||||||
import PollsListPage from '@/pages/public/PollsListPage';
|
import PollsListPage from '@/pages/public/PollsListPage';
|
||||||
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
||||||
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
|
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
|
||||||
import AdminCalendarPage from '@/pages/AdminCalendarPage';
|
|
||||||
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
|
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
|
||||||
import TicketedEventsPage from '@/pages/events/TicketedEventsPage';
|
import TicketedEventsPage from '@/pages/events/TicketedEventsPage';
|
||||||
import EventDetailPage from '@/pages/events/EventDetailPage';
|
import EventDetailPage from '@/pages/events/EventDetailPage';
|
||||||
@ -381,7 +392,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="/app/events/:id/checkin"
|
path="/app/events/:id/checkin"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
|
||||||
<FeatureGate feature="enableTicketedEvents">
|
<FeatureGate feature="enableTicketedEvents">
|
||||||
<CheckInScannerPage />
|
<CheckInScannerPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -422,7 +433,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="social"
|
path="social"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
|
||||||
<FeatureGate feature="enableSocial">
|
<FeatureGate feature="enableSocial">
|
||||||
<SocialDashboardPage />
|
<SocialDashboardPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -432,7 +443,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="social/graph"
|
path="social/graph"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
|
||||||
<FeatureGate feature="enableSocial">
|
<FeatureGate feature="enableSocial">
|
||||||
<SocialGraphPage />
|
<SocialGraphPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -442,7 +453,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="social/moderation"
|
path="social/moderation"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
|
||||||
<FeatureGate feature="enableSocial">
|
<FeatureGate feature="enableSocial">
|
||||||
<SocialModerationPage />
|
<SocialModerationPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -452,7 +463,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="social/referrals"
|
path="social/referrals"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
|
||||||
<FeatureGate feature="enableSocial">
|
<FeatureGate feature="enableSocial">
|
||||||
<ReferralAdminPage />
|
<ReferralAdminPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -462,7 +473,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="social/spotlights"
|
path="social/spotlights"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
|
||||||
<FeatureGate feature="enableSocial">
|
<FeatureGate feature="enableSocial">
|
||||||
<SpotlightAdminPage />
|
<SpotlightAdminPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -472,7 +483,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="social/challenges"
|
path="social/challenges"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SOCIAL_ROLES}>
|
||||||
<FeatureGate feature="enableSocial">
|
<FeatureGate feature="enableSocial">
|
||||||
<ChallengesAdminPage />
|
<ChallengesAdminPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -482,7 +493,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="campaigns"
|
path="campaigns"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
<CampaignsPage />
|
<CampaignsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -490,7 +501,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="representatives"
|
path="representatives"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
<RepresentativesPage />
|
<RepresentativesPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -498,7 +509,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="email-queue"
|
path="email-queue"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
<EmailQueuePage />
|
<EmailQueuePage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -506,7 +517,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="email-templates"
|
path="email-templates"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
|
||||||
<EmailTemplatesPage />
|
<EmailTemplatesPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -514,7 +525,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="responses"
|
path="responses"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
<ResponsesPage />
|
<ResponsesPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -522,7 +533,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="campaign-moderation"
|
path="campaign-moderation"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
<CampaignModerationPage />
|
<CampaignModerationPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -530,7 +541,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="influence/effectiveness"
|
path="influence/effectiveness"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
<CampaignEffectivenessPage />
|
<CampaignEffectivenessPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -538,7 +549,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="influence/stories"
|
path="influence/stories"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={INFLUENCE_ROLES}>
|
||||||
<ImpactStoriesPage />
|
<ImpactStoriesPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -546,7 +557,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="listmonk"
|
path="listmonk"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
|
||||||
<ListmonkPage />
|
<ListmonkPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -554,7 +565,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="pages"
|
path="pages"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
<LandingPagesPage />
|
<LandingPagesPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -562,7 +573,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="docs"
|
path="docs"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
<DocsPage />
|
<DocsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -570,7 +581,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="docs/settings"
|
path="docs/settings"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
<MkDocsSettingsPage />
|
<MkDocsSettingsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -578,7 +589,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="docs/analytics"
|
path="docs/analytics"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
<DocsAnalyticsPage />
|
<DocsAnalyticsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -586,7 +597,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="docs/comments"
|
path="docs/comments"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
<DocsCommentsPage />
|
<DocsCommentsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -594,7 +605,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="navigation"
|
path="navigation"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
<NavigationSettingsPage />
|
<NavigationSettingsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -602,7 +613,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="code"
|
path="code"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={CONTENT_ROLES}>
|
||||||
<CodeEditorPage />
|
<CodeEditorPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -610,7 +621,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/nocodb"
|
path="services/nocodb"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<NocoDBPage />
|
<NocoDBPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -618,7 +629,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/n8n"
|
path="services/n8n"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<N8nPage />
|
<N8nPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -626,7 +637,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/gitea"
|
path="services/gitea"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<GiteaPage />
|
<GiteaPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -634,7 +645,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/mailhog"
|
path="services/mailhog"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<MailHogPage />
|
<MailHogPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -642,7 +653,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/miniqr"
|
path="services/miniqr"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<MiniQRPage />
|
<MiniQRPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -650,7 +661,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/excalidraw"
|
path="services/excalidraw"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<ExcalidrawPage />
|
<ExcalidrawPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -658,7 +669,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/vaultwarden"
|
path="services/vaultwarden"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<VaultwardenPage />
|
<VaultwardenPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -674,7 +685,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/gancio"
|
path="services/gancio"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
|
||||||
<GancioPage />
|
<GancioPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -682,7 +693,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="services/jitsi"
|
path="services/jitsi"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
|
||||||
<JitsiMeetPage />
|
<JitsiMeetPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -699,7 +710,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="sms"
|
path="sms"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
|
||||||
<SmsDashboardPage />
|
<SmsDashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -707,7 +718,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="sms/contacts"
|
path="sms/contacts"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
|
||||||
<SmsContactsPage />
|
<SmsContactsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -715,7 +726,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="sms/campaigns"
|
path="sms/campaigns"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
|
||||||
<SmsCampaignsPage />
|
<SmsCampaignsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -723,7 +734,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="sms/conversations"
|
path="sms/conversations"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
|
||||||
<SmsConversationsPage />
|
<SmsConversationsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -731,7 +742,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="sms/templates"
|
path="sms/templates"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={BROADCAST_ROLES}>
|
||||||
<SmsTemplatesPage />
|
<SmsTemplatesPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -739,7 +750,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="settings"
|
path="settings"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<SettingsPage />
|
<SettingsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -747,7 +758,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="tunnel"
|
path="tunnel"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<PangolinPage />
|
<PangolinPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -755,7 +766,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="observability"
|
path="observability"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={SYSTEM_ROLES}>
|
||||||
<ObservabilityPage />
|
<ObservabilityPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -763,7 +774,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="map"
|
path="map"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MAP_ROLES}>
|
||||||
<LocationsPage />
|
<LocationsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -771,7 +782,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="map/data-quality"
|
path="map/data-quality"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MAP_ROLES}>
|
||||||
<DataQualityDashboardPage />
|
<DataQualityDashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -779,7 +790,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="map/shifts"
|
path="map/shifts"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SCHEDULING_ROLES}>
|
||||||
<ShiftsPage />
|
<ShiftsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -787,7 +798,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="meeting-planner"
|
path="meeting-planner"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
|
||||||
<MeetingPlannerPage />
|
<MeetingPlannerPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -795,23 +806,15 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="scheduling/calendar-views/:id"
|
path="scheduling/calendar-views/:id"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SCHEDULING_ROLES}>
|
||||||
<AdminCalendarViewPage />
|
<AdminCalendarViewPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Route
|
|
||||||
path="scheduling/calendar-views"
|
|
||||||
element={
|
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
|
||||||
<AdminCalendarPage />
|
|
||||||
</ProtectedRoute>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Route
|
<Route
|
||||||
path="scheduling/calendar"
|
path="scheduling/calendar"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={SCHEDULING_ROLES}>
|
||||||
<SchedulingCalendarPage />
|
<SchedulingCalendarPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -819,7 +822,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="events"
|
path="events"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
|
||||||
<FeatureGate feature="enableTicketedEvents">
|
<FeatureGate feature="enableTicketedEvents">
|
||||||
<TicketedEventsPage />
|
<TicketedEventsPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -829,7 +832,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="events/:id"
|
path="events/:id"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
|
||||||
<FeatureGate feature="enableTicketedEvents">
|
<FeatureGate feature="enableTicketedEvents">
|
||||||
<EventDetailPage />
|
<EventDetailPage />
|
||||||
</FeatureGate>
|
</FeatureGate>
|
||||||
@ -839,7 +842,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="map/cuts"
|
path="map/cuts"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MAP_ROLES}>
|
||||||
<CutsPage />
|
<CutsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -847,7 +850,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="map/settings"
|
path="map/settings"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MAP_ROLES}>
|
||||||
<MapSettingsPage />
|
<MapSettingsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -855,7 +858,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="map/cuts/:id/export"
|
path="map/cuts/:id/export"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MAP_ROLES}>
|
||||||
<CutExportPage />
|
<CutExportPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -863,7 +866,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="map/canvass"
|
path="map/canvass"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MAP_ROLES}>
|
||||||
<CanvassDashboardPage />
|
<CanvassDashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -871,7 +874,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="media/library"
|
path="media/library"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
|
||||||
<LibraryPage />
|
<LibraryPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -879,7 +882,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="media/analytics"
|
path="media/analytics"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
|
||||||
<AnalyticsDashboardPage />
|
<AnalyticsDashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -887,7 +890,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="media/jobs"
|
path="media/jobs"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
|
||||||
<MediaJobsPage />
|
<MediaJobsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -895,7 +898,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="media/curated"
|
path="media/curated"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
|
||||||
<PlaylistManagementPage />
|
<PlaylistManagementPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -903,7 +906,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="media/moderation"
|
path="media/moderation"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={ADMIN_ROLES}>
|
<ProtectedRoute requiredRoles={MEDIA_ROLES}>
|
||||||
<CommentModerationPage />
|
<CommentModerationPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -911,7 +914,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments/ads/analytics"
|
path="payments/ads/analytics"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<AdAnalyticsDashboardPage />
|
<AdAnalyticsDashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -919,7 +922,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments/ads"
|
path="payments/ads"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<GalleryAdsPage />
|
<GalleryAdsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -927,7 +930,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments"
|
path="payments"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<PaymentsDashboardPage />
|
<PaymentsDashboardPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -935,7 +938,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments/plans"
|
path="payments/plans"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<PlansPage />
|
<PlansPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -943,7 +946,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments/subscribers"
|
path="payments/subscribers"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<SubscribersPage />
|
<SubscribersPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -951,7 +954,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments/products"
|
path="payments/products"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<PaymentProductsPage />
|
<PaymentProductsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -959,7 +962,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments/donations"
|
path="payments/donations"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<PaymentDonationsPage />
|
<PaymentDonationsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -967,7 +970,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments/donation-pages"
|
path="payments/donation-pages"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<DonationPagesPage />
|
<DonationPagesPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
@ -975,7 +978,7 @@ export default function App() {
|
|||||||
<Route
|
<Route
|
||||||
path="payments/settings"
|
path="payments/settings"
|
||||||
element={
|
element={
|
||||||
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
|
<ProtectedRoute requiredRoles={PAYMENTS_ROLES}>
|
||||||
<PaymentSettingsPage />
|
<PaymentSettingsPage />
|
||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -60,7 +60,17 @@ import { api } from '@/lib/api';
|
|||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { hasAnyRole } from '@/utils/roles';
|
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 { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
|
||||||
import type { NavItem } from '@/types/api';
|
import type { NavItem } from '@/types/api';
|
||||||
import {
|
import {
|
||||||
@ -122,7 +132,10 @@ const ADMIN_ICON_OVERRIDES: Record<string, React.ReactNode> = {
|
|||||||
PlayCircleOutlined: <PlaySquareOutlined />,
|
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'] = [
|
const items: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
key: '/app',
|
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'] = [];
|
const communityChildren: MenuProps['items'] = [];
|
||||||
if (settings?.enablePeople) {
|
if (settings?.enablePeople) {
|
||||||
communityChildren.push({ key: '/app/people', icon: <ContactsOutlined />, label: 'People' });
|
communityChildren.push({ key: '/app/people', icon: <ContactsOutlined />, label: 'People' });
|
||||||
}
|
}
|
||||||
communityChildren.push({ key: '/app/users', icon: <TeamOutlined />, label: 'Users' });
|
communityChildren.push({ key: '/app/users', icon: <TeamOutlined />, label: 'Users' });
|
||||||
if (settings?.enableSocial) {
|
if (settings?.enableSocial && can(SOCIAL_ROLES)) {
|
||||||
communityChildren.push(
|
communityChildren.push(
|
||||||
{ key: '/app/social', icon: <HeartOutlined />, label: 'Social Dashboard' },
|
{ key: '/app/social', icon: <HeartOutlined />, label: 'Social Dashboard' },
|
||||||
{ key: '/app/social/graph', icon: <ApartmentOutlined />, label: 'Social Graph' },
|
{ 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({
|
items.push({
|
||||||
key: 'influence-submenu',
|
key: 'influence-submenu',
|
||||||
icon: <SendOutlined />,
|
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'] = [
|
const broadcastChildren: MenuProps['items'] = [
|
||||||
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
|
{ key: '/app/listmonk', icon: <MailOutlined />, label: 'Newsletter' },
|
||||||
{ key: '/app/email-templates', icon: <FileTextOutlined />, label: 'Email Templates' },
|
{ 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
|
// Web submenu — conditionally include Landing Pages
|
||||||
const webChildren: MenuProps['items'] = [];
|
if (can(CONTENT_ROLES)) {
|
||||||
if (settings?.enableLandingPages !== false) {
|
const webChildren: MenuProps['items'] = [];
|
||||||
webChildren.push({ key: '/app/pages', icon: <FileTextOutlined />, label: 'Landing Pages' });
|
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({
|
items.push({
|
||||||
key: 'map-submenu',
|
key: 'map-submenu',
|
||||||
icon: <EnvironmentOutlined />,
|
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
|
// Scheduling submenu — visible if relevant features are enabled AND user has SCHEDULING_ROLES
|
||||||
if (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents || settings?.enableMeet || settings?.enableEvents) {
|
if (can(SCHEDULING_ROLES) && (settings?.enableMap !== false || settings?.enableMeetingPlanner || settings?.enableTicketedEvents || settings?.enableMeet || settings?.enableEvents)) {
|
||||||
const schedulingChildren: any[] = [];
|
const schedulingChildren: any[] = [];
|
||||||
if (settings?.enableMap !== false) {
|
if (settings?.enableMap !== false) {
|
||||||
schedulingChildren.push({ key: '/app/map/shifts', icon: <ScheduleOutlined />, label: 'Shifts' });
|
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) {
|
if (settings?.enableEvents) {
|
||||||
schedulingChildren.push({ key: '/app/services/gancio', icon: <GlobalOutlined />, label: 'Gancio' });
|
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
|
// Always add Calendar as the last item in scheduling
|
||||||
schedulingChildren.push({ key: '/app/scheduling/calendar', icon: <CalendarOutlined />, label: 'Calendar' });
|
schedulingChildren.push({ key: '/app/scheduling/calendar', icon: <CalendarOutlined />, label: 'Calendar' });
|
||||||
if (schedulingChildren.length > 0) {
|
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({
|
items.push({
|
||||||
key: 'media-submenu',
|
key: 'media-submenu',
|
||||||
icon: <PlaySquareOutlined />,
|
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({
|
items.push({
|
||||||
key: 'payments-submenu',
|
key: 'payments-submenu',
|
||||||
icon: <DollarOutlined />,
|
icon: <DollarOutlined />,
|
||||||
@ -323,13 +337,15 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
items.push(
|
if (isSuperAdmin) {
|
||||||
{
|
items.push(
|
||||||
key: '/app/settings',
|
{
|
||||||
icon: <SettingOutlined />,
|
key: '/app/settings',
|
||||||
label: 'Settings',
|
icon: <SettingOutlined />,
|
||||||
},
|
label: 'Settings',
|
||||||
);
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@ -345,7 +361,6 @@ export default function AppLayout() {
|
|||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
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 [badgeCounts, setBadgeCounts] = useState<{ pendingResponses: number; pendingEmails: number; pendingCampaignReview: number; pendingComments: number }>({ pendingResponses: 0, pendingEmails: 0, pendingCampaignReview: 0, pendingComments: 0 });
|
||||||
|
|
||||||
const fetchBadges = useCallback(() => {
|
const fetchBadges = useCallback(() => {
|
||||||
@ -365,7 +380,7 @@ export default function AppLayout() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [fetchBadges]);
|
}, [fetchBadges]);
|
||||||
|
|
||||||
const baseMenuItems = buildMenuItems(settings, isSuperAdmin, badgeCounts);
|
const baseMenuItems = buildMenuItems(settings, user, badgeCounts);
|
||||||
const { favorites } = useFavoritesStore();
|
const { favorites } = useFavoritesStore();
|
||||||
|
|
||||||
// Build final menu: resolve favorites, add stars, prepend favorites section
|
// Build final menu: resolve favorites, add stars, prepend favorites section
|
||||||
|
|||||||
@ -32,6 +32,7 @@ import {
|
|||||||
isItemActive,
|
isItemActive,
|
||||||
} from '@/lib/nav-defaults';
|
} from '@/lib/nav-defaults';
|
||||||
import type { NavItem } from '@/types/api';
|
import type { NavItem } from '@/types/api';
|
||||||
|
import { isAdmin as checkIsAdmin } from '@/utils/roles';
|
||||||
|
|
||||||
const navItemStyle: React.CSSProperties = {
|
const navItemStyle: React.CSSProperties = {
|
||||||
color: 'rgba(255, 255, 255, 0.85)',
|
color: 'rgba(255, 255, 255, 0.85)',
|
||||||
@ -65,7 +66,7 @@ interface PublicNavBarProps {
|
|||||||
export default function PublicNavBar({ activePath, showAuth = true, onSignInClick }: PublicNavBarProps) {
|
export default function PublicNavBar({ activePath, showAuth = true, onSignInClick }: PublicNavBarProps) {
|
||||||
const { settings } = useSettingsStore();
|
const { settings } = useSettingsStore();
|
||||||
const { isAuthenticated, logout, user } = useAuthStore();
|
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 location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|||||||
@ -21,6 +21,7 @@ import VolunteerFooterNav from '@/components/VolunteerFooterNav';
|
|||||||
import PublicNavBar from '@/components/PublicNavBar';
|
import PublicNavBar from '@/components/PublicNavBar';
|
||||||
import { useSSE } from '@/hooks/useSSE';
|
import { useSSE } from '@/hooks/useSSE';
|
||||||
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
import { useLocalStorage } from '@/hooks/useLocalStorage';
|
||||||
|
import { isAdmin as checkIsAdmin } from '@/utils/roles';
|
||||||
|
|
||||||
const { Content, Footer } = Layout;
|
const { Content, Footer } = Layout;
|
||||||
|
|
||||||
@ -35,7 +36,7 @@ export default function VolunteerLayout() {
|
|||||||
// Initialize SSE connection for real-time notifications + online presence
|
// Initialize SSE connection for real-time notifications + online presence
|
||||||
useSSE();
|
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 colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a';
|
||||||
const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838';
|
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 { useMemo } from 'react';
|
||||||
import { Calendar, Spin, Empty, theme } from 'antd';
|
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 { Dayjs } from 'dayjs';
|
||||||
import type { PersonalCalendarItem, CalendarLayer } from '@/types/api';
|
import type { PersonalCalendarItem, CalendarLayer } from '@/types/api';
|
||||||
|
import { hexToRgba, formatTimeShort } from './calendarUtils';
|
||||||
|
|
||||||
const { useToken } = theme;
|
const { useToken } = theme;
|
||||||
|
|
||||||
@ -22,6 +23,7 @@ const MAX_CELL_ITEMS = 3;
|
|||||||
export default function PersonalCalendarView({
|
export default function PersonalCalendarView({
|
||||||
items,
|
items,
|
||||||
loading,
|
loading,
|
||||||
|
currentMonth,
|
||||||
onDateSelect,
|
onDateSelect,
|
||||||
onItemClick,
|
onItemClick,
|
||||||
onMonthChange,
|
onMonthChange,
|
||||||
@ -39,7 +41,6 @@ export default function PersonalCalendarView({
|
|||||||
map[item.date] = [item];
|
map[item.date] = [item];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Sort items within each date by startTime
|
|
||||||
for (const key of Object.keys(map)) {
|
for (const key of Object.keys(map)) {
|
||||||
map[key]!.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
map[key]!.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
||||||
}
|
}
|
||||||
@ -77,6 +78,7 @@ export default function PersonalCalendarView({
|
|||||||
const color = item.color || '#1890ff';
|
const color = item.color || '#1890ff';
|
||||||
const bgAlpha = isTimeBlock ? 0.1 : 0.2;
|
const bgAlpha = isTimeBlock ? 0.1 : 0.2;
|
||||||
const borderAlpha = isTimeBlock ? 0.3 : 0.5;
|
const borderAlpha = isTimeBlock ? 0.3 : 0.5;
|
||||||
|
const isSystemType = item.type !== 'personal';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -93,8 +95,8 @@ export default function PersonalCalendarView({
|
|||||||
borderLeft: `3px solid ${color}`,
|
borderLeft: `3px solid ${color}`,
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
padding: '2px 5px',
|
padding: '2px 5px',
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
lineHeight: '15px',
|
lineHeight: '16px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
@ -105,13 +107,31 @@ export default function PersonalCalendarView({
|
|||||||
>
|
>
|
||||||
{!item.isAllDay && (
|
{!item.isAllDay && (
|
||||||
<span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}>
|
<span style={{ color: 'rgba(255,255,255,0.5)', marginRight: 3, fontSize: 10 }}>
|
||||||
{item.startTime}
|
{formatTimeShort(item.startTime)}-{formatTimeShort(item.endTime)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isReminder && (
|
{isReminder && (
|
||||||
<BellOutlined style={{ fontSize: 9, marginRight: 3, color: 'rgba(255,255,255,0.5)' }} />
|
<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.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>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -141,9 +161,11 @@ export default function PersonalCalendarView({
|
|||||||
)}
|
)}
|
||||||
<Calendar
|
<Calendar
|
||||||
fullscreen
|
fullscreen
|
||||||
|
value={currentMonth}
|
||||||
cellRender={(date) => cellRender(date)}
|
cellRender={(date) => cellRender(date)}
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
onPanelChange={handlePanelChange}
|
onPanelChange={handlePanelChange}
|
||||||
|
headerRender={() => null}
|
||||||
/>
|
/>
|
||||||
{items.length === 0 && (
|
{items.length === 0 && (
|
||||||
<div
|
<div
|
||||||
@ -161,13 +183,3 @@ export default function PersonalCalendarView({
|
|||||||
</div>
|
</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'],
|
keywords: ['social', 'community', 'friends', 'connections', 'engagement'],
|
||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
featureFlag: 'enableSocial',
|
featureFlag: 'enableSocial',
|
||||||
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN', 'SOCIAL_ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nav-social-graph',
|
id: 'nav-social-graph',
|
||||||
@ -76,7 +76,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
keywords: ['network', 'connections', 'graph', 'relationships', 'visualization'],
|
keywords: ['network', 'connections', 'graph', 'relationships', 'visualization'],
|
||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
featureFlag: 'enableSocial',
|
featureFlag: 'enableSocial',
|
||||||
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN', 'SOCIAL_ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nav-social-moderation',
|
id: 'nav-social-moderation',
|
||||||
@ -88,7 +88,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
keywords: ['moderation', 'reports', 'flagged', 'content review', 'social moderation'],
|
keywords: ['moderation', 'reports', 'flagged', 'content review', 'social moderation'],
|
||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
featureFlag: 'enableSocial',
|
featureFlag: 'enableSocial',
|
||||||
requiredRoles: ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN', 'SOCIAL_ADMIN'],
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── Navigation: Advocacy ──────────────────────────────
|
// ── Navigation: Advocacy ──────────────────────────────
|
||||||
@ -170,7 +170,7 @@ export const commandRegistry: CommandItem[] = [
|
|||||||
keywords: ['listmonk', 'mailing list', 'subscribers', 'broadcast', 'email marketing'],
|
keywords: ['listmonk', 'mailing list', 'subscribers', 'broadcast', 'email marketing'],
|
||||||
category: 'navigation',
|
category: 'navigation',
|
||||||
featureFlag: 'enableNewsletter',
|
featureFlag: 'enableNewsletter',
|
||||||
requiredRoles: ['SUPER_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN', 'BROADCAST_ADMIN'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'nav-email-templates',
|
id: 'nav-email-templates',
|
||||||
|
|||||||
@ -81,7 +81,7 @@ const entityConfigs: EntitySearchConfig[] = [
|
|||||||
subtitleField: 'slug',
|
subtitleField: 'slug',
|
||||||
pathPrefix: '/app/payments/products',
|
pathPrefix: '/app/payments/products',
|
||||||
featureFlag: 'enablePayments',
|
featureFlag: 'enablePayments',
|
||||||
requiredRoles: ['SUPER_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN', 'PAYMENTS_ADMIN'],
|
||||||
extractItems: (data: unknown) => (data as { products?: unknown[] })?.products ?? [],
|
extractItems: (data: unknown) => (data as { products?: unknown[] })?.products ?? [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -91,7 +91,7 @@ const entityConfigs: EntitySearchConfig[] = [
|
|||||||
subtitleField: 'slug',
|
subtitleField: 'slug',
|
||||||
pathPrefix: '/app/payments/donation-pages',
|
pathPrefix: '/app/payments/donation-pages',
|
||||||
featureFlag: 'enablePayments',
|
featureFlag: 'enablePayments',
|
||||||
requiredRoles: ['SUPER_ADMIN'],
|
requiredRoles: ['SUPER_ADMIN', 'PAYMENTS_ADMIN'],
|
||||||
extractItems: (data: unknown) => (data as { donationPages?: unknown[] })?.donationPages ?? [],
|
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 { AdPickerModal } from '@/components/media/AdPickerModal';
|
||||||
import type { AdInsertResult } from '@/components/media/AdPickerModal';
|
import type { AdInsertResult } from '@/components/media/AdPickerModal';
|
||||||
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
|
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';
|
type MobileTab = 'files' | 'editor' | 'preview';
|
||||||
|
|
||||||
interface MobileDocsEditorProps {
|
interface MobileDocsEditorProps {
|
||||||
editor: UseDocsEditorReturn;
|
editor: UseDocsEditorReturn;
|
||||||
|
collabEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flatten file tree into a searchable list of file paths
|
// Flatten file tree into a searchable list of file paths
|
||||||
@ -105,7 +109,49 @@ function LineNumberedEditor({
|
|||||||
token: ReturnType<typeof theme.useToken>['token'];
|
token: ReturnType<typeof theme.useToken>['token'];
|
||||||
}) {
|
}) {
|
||||||
const gutterRef = useRef<HTMLDivElement>(null);
|
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
|
// Sync gutter scroll with textarea scroll
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
@ -123,7 +169,22 @@ function LineNumberedEditor({
|
|||||||
}, [textareaRef, handleScroll]);
|
}, [textareaRef, handleScroll]);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Line number gutter */}
|
||||||
<div
|
<div
|
||||||
ref={gutterRef}
|
ref={gutterRef}
|
||||||
@ -143,8 +204,8 @@ function LineNumberedEditor({
|
|||||||
paddingRight: 6,
|
paddingRight: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{Array.from({ length: lineCount }, (_, i) => (
|
{(lineHeights.length > 0 ? lineHeights : lines.map(() => LINE_HEIGHT)).map((h, i) => (
|
||||||
<div key={i + 1} style={{ height: LINE_HEIGHT }}>{i + 1}</div>
|
<div key={i + 1} style={{ height: h }}>{i + 1}</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Textarea */}
|
{/* Textarea */}
|
||||||
@ -169,7 +230,9 @@ function LineNumberedEditor({
|
|||||||
color: token.colorText,
|
color: token.colorText,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre-wrap',
|
||||||
|
wordBreak: 'break-all',
|
||||||
|
overflowX: 'hidden',
|
||||||
WebkitTextSizeAdjust: 'none',
|
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 { token } = theme.useToken();
|
||||||
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
||||||
const [activeTab, setActiveTab] = useState<MobileTab>('files');
|
const [activeTab, setActiveTab] = useState<MobileTab>('files');
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const yBindingRef = useRef<YTextareaBinding | null>(null);
|
||||||
|
|
||||||
// Insert modal state
|
// Insert modal state
|
||||||
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
|
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
|
||||||
@ -231,6 +295,30 @@ export function MobileDocsEditor({ editor }: MobileDocsEditorProps) {
|
|||||||
contextHolder,
|
contextHolder,
|
||||||
} = editor;
|
} = 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]);
|
const treeData = useMemo(() => fileNodeToTreeData(filteredTree), [filteredTree]);
|
||||||
|
|
||||||
// Flat file list for search results
|
// 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>
|
<Typography.Text style={{ fontFamily: 'monospace', fontSize: 11, flex: 1, color: token.colorTextSecondary }} ellipsis>
|
||||||
{selectedFile}
|
{selectedFile}
|
||||||
</Typography.Text>
|
</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 }} />
|
<span style={{ width: 6, height: 6, borderRadius: '50%', background: token.colorWarning, flexShrink: 0 }} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -10,6 +10,12 @@ const roleColors: Record<UserRole, string> = {
|
|||||||
SUPER_ADMIN: 'red',
|
SUPER_ADMIN: 'red',
|
||||||
INFLUENCE_ADMIN: 'volcano',
|
INFLUENCE_ADMIN: 'volcano',
|
||||||
MAP_ADMIN: 'orange',
|
MAP_ADMIN: 'orange',
|
||||||
|
BROADCAST_ADMIN: 'gold',
|
||||||
|
CONTENT_ADMIN: 'lime',
|
||||||
|
MEDIA_ADMIN: 'purple',
|
||||||
|
PAYMENTS_ADMIN: 'green',
|
||||||
|
EVENTS_ADMIN: 'cyan',
|
||||||
|
SOCIAL_ADMIN: 'magenta',
|
||||||
USER: 'blue',
|
USER: 'blue',
|
||||||
TEMP: 'default',
|
TEMP: 'default',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -86,6 +86,8 @@ interface PollData {
|
|||||||
finalizedOptionId: string | null;
|
finalizedOptionId: string | null;
|
||||||
finalizedOption: PollOption | null;
|
finalizedOption: PollOption | null;
|
||||||
allowAnonymous: boolean;
|
allowAnonymous: boolean;
|
||||||
|
isPrivate?: boolean;
|
||||||
|
requiresAuth?: boolean;
|
||||||
createdBy?: { name: string | null; email: string };
|
createdBy?: { name: string | null; email: string };
|
||||||
options: PollOption[];
|
options: PollOption[];
|
||||||
voters: PollVoter[];
|
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 isOpen = poll.status === 'OPEN';
|
||||||
const isFinalized = poll.status === 'FINALIZED';
|
const isFinalized = poll.status === 'FINALIZED';
|
||||||
const bestScore = poll.options.length ? Math.max(...poll.options.map((o) => o.score ?? 0)) : 0;
|
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: '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: '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: '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' },
|
{ 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);
|
setTotalUsers(itemsRes.data.totalUsers);
|
||||||
setTruncated(itemsRes.data.truncated);
|
setTruncated(itemsRes.data.truncated);
|
||||||
} catch {
|
} catch {
|
||||||
navigate('/app/scheduling/calendar-views');
|
navigate('/app/scheduling/calendar');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -221,7 +221,7 @@ export default function AdminCalendarViewPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={() => navigate('/app/scheduling/calendar-views')}
|
onClick={() => navigate('/app/scheduling/calendar')}
|
||||||
/>
|
/>
|
||||||
<Title level={5} style={{ margin: 0 }}>{view.name}</Title>
|
<Title level={5} style={{ margin: 0 }}>{view.name}</Title>
|
||||||
</Space>
|
</Space>
|
||||||
@ -264,7 +264,7 @@ export default function AdminCalendarViewPage() {
|
|||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
onClick={() => navigate('/app/scheduling/calendar-views')}
|
onClick={() => navigate('/app/scheduling/calendar')}
|
||||||
/>
|
/>
|
||||||
<CalendarOutlined style={{ fontSize: 18 }} />
|
<CalendarOutlined style={{ fontSize: 18 }} />
|
||||||
<Title level={4} style={{ margin: 0 }}>{view.name}</Title>
|
<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 { AdPickerModal } from '@/components/media/AdPickerModal';
|
||||||
import type { AdInsertResult } from '@/components/media/AdPickerModal';
|
import type { AdInsertResult } from '@/components/media/AdPickerModal';
|
||||||
import { PollInsertModal } from '@/components/scheduling/PollInsertModal';
|
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 LayoutMode = 'split' | 'editor' | 'preview';
|
||||||
type PreviewMode = 'desktop' | 'mobile';
|
type PreviewMode = 'desktop' | 'mobile';
|
||||||
@ -551,7 +555,13 @@ function applySnippet(
|
|||||||
/** Wrapper component so useDocsEditor() hook only runs on mobile */
|
/** Wrapper component so useDocsEditor() hook only runs on mobile */
|
||||||
function MobileDocsEditorWrapper() {
|
function MobileDocsEditorWrapper() {
|
||||||
const editor = useDocsEditor();
|
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() {
|
export default function DocsPage() {
|
||||||
@ -612,6 +622,20 @@ export default function DocsPage() {
|
|||||||
const previewIframeRef = useRef<HTMLIFrameElement>(null);
|
const previewIframeRef = useRef<HTMLIFrameElement>(null);
|
||||||
const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
|
const monacoEditorRef = useRef<monacoEditor.IStandaloneCodeEditor | null>(null);
|
||||||
const monacoRef = useRef<typeof import('monaco-editor') | 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();
|
const [messageApi, contextHolder] = message.useMessage();
|
||||||
|
|
||||||
@ -756,12 +780,17 @@ export default function DocsPage() {
|
|||||||
const handler = (e: KeyboardEvent) => {
|
const handler = (e: KeyboardEvent) => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
e.preventDefault();
|
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);
|
window.addEventListener('keydown', handler);
|
||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [saveFile]);
|
}, [saveFile, collab.active]);
|
||||||
|
|
||||||
const onEditorChange = useCallback((value: string | undefined) => {
|
const onEditorChange = useCallback((value: string | undefined) => {
|
||||||
const v = value ?? '';
|
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) => {
|
const handleToolbarSnippet = useCallback((snippetId: string) => {
|
||||||
if (snippetId === 'video-card') {
|
if (snippetId === 'video-card') {
|
||||||
setVideoPickerOpen(true);
|
setVideoPickerOpen(true);
|
||||||
@ -1443,8 +1519,12 @@ export default function DocsPage() {
|
|||||||
|
|
||||||
// Inject header
|
// Inject header
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isMobile && !loading && !fetchError) {
|
if (!loading && !fetchError) {
|
||||||
setPageHeader({ title: 'Documentation', actions: headerActions, fullBleed: true });
|
if (isMobile) {
|
||||||
|
setPageHeader({ fullBleed: true });
|
||||||
|
} else {
|
||||||
|
setPageHeader({ title: 'Documentation', actions: headerActions, fullBleed: true });
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setPageHeader(null);
|
setPageHeader(null);
|
||||||
}
|
}
|
||||||
@ -1934,7 +2014,11 @@ export default function DocsPage() {
|
|||||||
{selectedFile ? (
|
{selectedFile ? (
|
||||||
<>
|
<>
|
||||||
<span style={{ fontFamily: 'monospace' }}>{selectedFile}</span>
|
<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>
|
<span>Select a file from the tree</span>
|
||||||
@ -2061,8 +2145,7 @@ export default function DocsPage() {
|
|||||||
<Editor
|
<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'}
|
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"
|
theme="vs-dark"
|
||||||
value={fileContent}
|
{...(collab.active ? {} : { value: fileContent, onChange: onEditorChange })}
|
||||||
onChange={onEditorChange}
|
|
||||||
onMount={handleEditorMount}
|
onMount={handleEditorMount}
|
||||||
options={{
|
options={{
|
||||||
minimap: { enabled: false },
|
minimap: { enabled: false },
|
||||||
|
|||||||
@ -149,6 +149,7 @@ export default function MeetingPlannerPage() {
|
|||||||
location: values.location,
|
location: values.location,
|
||||||
timezone: values.timezone,
|
timezone: values.timezone,
|
||||||
allowAnonymous: values.allowAnonymous ?? true,
|
allowAnonymous: values.allowAnonymous ?? true,
|
||||||
|
isPrivate: values.isPrivate ?? false,
|
||||||
notifyOnVote: values.notifyOnVote ?? true,
|
notifyOnVote: values.notifyOnVote ?? true,
|
||||||
votingDeadline: values.votingDeadline?.toISOString(),
|
votingDeadline: values.votingDeadline?.toISOString(),
|
||||||
options,
|
options,
|
||||||
@ -254,6 +255,7 @@ export default function MeetingPlannerPage() {
|
|||||||
location: data.location || '',
|
location: data.location || '',
|
||||||
timezone: data.timezone,
|
timezone: data.timezone,
|
||||||
allowAnonymous: data.allowAnonymous,
|
allowAnonymous: data.allowAnonymous,
|
||||||
|
isPrivate: data.isPrivate,
|
||||||
notifyOnVote: data.notifyOnVote,
|
notifyOnVote: data.notifyOnVote,
|
||||||
votingDeadline: data.votingDeadline ? dayjs(data.votingDeadline) : null,
|
votingDeadline: data.votingDeadline ? dayjs(data.votingDeadline) : null,
|
||||||
});
|
});
|
||||||
@ -273,6 +275,7 @@ export default function MeetingPlannerPage() {
|
|||||||
location: values.location || null,
|
location: values.location || null,
|
||||||
timezone: values.timezone,
|
timezone: values.timezone,
|
||||||
allowAnonymous: values.allowAnonymous,
|
allowAnonymous: values.allowAnonymous,
|
||||||
|
isPrivate: values.isPrivate,
|
||||||
notifyOnVote: values.notifyOnVote,
|
notifyOnVote: values.notifyOnVote,
|
||||||
votingDeadline: values.votingDeadline?.toISOString() || null,
|
votingDeadline: values.votingDeadline?.toISOString() || null,
|
||||||
});
|
});
|
||||||
@ -607,6 +610,7 @@ export default function MeetingPlannerPage() {
|
|||||||
initialValues={{
|
initialValues={{
|
||||||
timezone: 'America/Edmonton',
|
timezone: 'America/Edmonton',
|
||||||
allowAnonymous: true,
|
allowAnonymous: true,
|
||||||
|
isPrivate: false,
|
||||||
notifyOnVote: true,
|
notifyOnVote: true,
|
||||||
options: [
|
options: [
|
||||||
{ date: null, startTime: null, endTime: null },
|
{ date: null, startTime: null, endTime: null },
|
||||||
@ -669,12 +673,17 @@ export default function MeetingPlannerPage() {
|
|||||||
<Divider>Settings</Divider>
|
<Divider>Settings</Divider>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={8}>
|
||||||
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
|
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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">
|
<Form.Item name="notifyOnVote" label="Notify on Vote" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@ -857,12 +866,17 @@ export default function MeetingPlannerPage() {
|
|||||||
<Divider>Settings</Divider>
|
<Divider>Settings</Divider>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={8}>
|
||||||
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
|
<Form.Item name="allowAnonymous" label="Allow Anonymous" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</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">
|
<Form.Item name="notifyOnVote" label="Notify on Vote" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@ -1,31 +1,209 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef, useState, useEffect, useCallback } from 'react';
|
||||||
import { Typography, Space } from 'antd';
|
import {
|
||||||
import { CalendarOutlined } from '@ant-design/icons';
|
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 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';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
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() {
|
export default function SchedulingCalendarPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const addEventRef = useRef<(() => void) | null>(null);
|
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) => {
|
const handleShiftClick = (item: UnifiedCalendarItem) => {
|
||||||
if (item.shiftId) {
|
if (item.shiftId) {
|
||||||
navigate('/app/map/shifts');
|
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 (
|
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 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
|
||||||
<Title level={3} style={{ margin: 0 }}>
|
<Title level={3} style={{ margin: 0 }}>
|
||||||
<CalendarOutlined style={{ marginRight: 8 }} />
|
<CalendarOutlined style={{ marginRight: 8 }} />
|
||||||
Scheduling Calendar
|
Scheduling Calendar
|
||||||
</Title>
|
</Title>
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<Space size={12} wrap>
|
<Space size={12} wrap>
|
||||||
<Space size={4}>
|
<Space size={4}>
|
||||||
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#1890ff' }} />
|
<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' }} />
|
<div style={{ width: 12, height: 12, borderRadius: 2, background: '#52c41a' }} />
|
||||||
<Text type="secondary" style={{ fontSize: 13 }}>Community Events</Text>
|
<Text type="secondary" style={{ fontSize: 13 }}>Community Events</Text>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
icon={<TeamOutlined />}
|
||||||
|
type={viewsOpen ? 'primary' : 'default'}
|
||||||
|
onClick={() => viewsOpen ? closeViews() : setViewsOpen(true)}
|
||||||
|
>
|
||||||
|
Shared Views
|
||||||
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -50,6 +236,72 @@ export default function SchedulingCalendarPage() {
|
|||||||
onShiftSignup={handleShiftClick}
|
onShiftSignup={handleShiftClick}
|
||||||
onAddEvent={addEventRef}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Timeline,
|
Timeline,
|
||||||
|
Select,
|
||||||
message,
|
message,
|
||||||
Spin,
|
Spin,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@ -53,11 +54,12 @@ import {
|
|||||||
ExclamationCircleOutlined,
|
ExclamationCircleOutlined,
|
||||||
LoadingOutlined,
|
LoadingOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
ClockCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useSettingsStore } from '@/stores/settings.store';
|
import { useSettingsStore } from '@/stores/settings.store';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
import type { AppOutletContext } from '@/components/AppLayout';
|
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;
|
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 }}>
|
<Form.Item label="Gallery Ads" name="enableGalleryAds" valuePropName="checked" extra="Promotional cards inserted into the public video gallery" style={{ marginBottom: 12 }}>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</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 />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Card>
|
</Card>
|
||||||
@ -697,6 +702,7 @@ const UPGRADE_PHASES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function SystemUpgradeTab() {
|
function SystemUpgradeTab() {
|
||||||
|
const { settings, updateSettings } = useSettingsStore();
|
||||||
const [status, setStatus] = useState<UpgradeStatus | null>(null);
|
const [status, setStatus] = useState<UpgradeStatus | null>(null);
|
||||||
const [progress, setProgress] = useState<UpgradeProgress | null>(null);
|
const [progress, setProgress] = useState<UpgradeProgress | null>(null);
|
||||||
const [result, setResult] = useState<UpgradeResult | null>(null);
|
const [result, setResult] = useState<UpgradeResult | null>(null);
|
||||||
@ -707,6 +713,7 @@ function SystemUpgradeTab() {
|
|||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [skipBackup, setSkipBackup] = useState(false);
|
const [skipBackup, setSkipBackup] = useState(false);
|
||||||
const [pullServices, setPullServices] = useState(false);
|
const [pullServices, setPullServices] = useState(false);
|
||||||
|
const [history, setHistory] = useState<UpgradeResult[]>([]);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const checkStartRef = useRef<string | 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
|
// Initial fetch on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchStatus().then((data) => {
|
fetchStatus().then((data) => {
|
||||||
@ -734,6 +750,7 @@ function SystemUpgradeTab() {
|
|||||||
startUpgradePoll();
|
startUpgradePoll();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
fetchHistory();
|
||||||
return () => stopPoll();
|
return () => stopPoll();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
@ -758,12 +775,10 @@ function SystemUpgradeTab() {
|
|||||||
message.success('Update check complete');
|
message.success('Update check complete');
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
// Auto-stop after 30s
|
// Auto-stop after 30s (idempotent — harmless if check already completed)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (checking) {
|
setChecking(false);
|
||||||
setChecking(false);
|
stopPoll();
|
||||||
stopPoll();
|
|
||||||
}
|
|
||||||
}, 30000);
|
}, 30000);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -777,6 +792,7 @@ function SystemUpgradeTab() {
|
|||||||
if (data.result && !data.running) {
|
if (data.result && !data.running) {
|
||||||
setUpgrading(false);
|
setUpgrading(false);
|
||||||
stopPoll();
|
stopPoll();
|
||||||
|
fetchHistory(); // Refresh history after upgrade
|
||||||
if (data.result.success) {
|
if (data.result.success) {
|
||||||
message.success('Upgrade completed successfully');
|
message.success('Upgrade completed successfully');
|
||||||
} else {
|
} 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) => {
|
const formatDate = (dateStr: string) => {
|
||||||
try {
|
try {
|
||||||
return new Date(dateStr).toLocaleString();
|
return new Date(dateStr).toLocaleString();
|
||||||
@ -845,6 +869,7 @@ function SystemUpgradeTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isUpgrading = upgrading || running;
|
const isUpgrading = upgrading || running;
|
||||||
|
const autoUpgradeEnabled = settings?.enableAutoUpgrade ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 800 }}>
|
<div style={{ maxWidth: 800 }}>
|
||||||
@ -1050,6 +1075,135 @@ function SystemUpgradeTab() {
|
|||||||
</Card>
|
</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 */}
|
{/* Systemd Setup Info */}
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@ -74,6 +74,12 @@ const roleColors: Record<UserRole, string> = {
|
|||||||
SUPER_ADMIN: 'red',
|
SUPER_ADMIN: 'red',
|
||||||
INFLUENCE_ADMIN: 'volcano',
|
INFLUENCE_ADMIN: 'volcano',
|
||||||
MAP_ADMIN: 'orange',
|
MAP_ADMIN: 'orange',
|
||||||
|
BROADCAST_ADMIN: 'gold',
|
||||||
|
CONTENT_ADMIN: 'lime',
|
||||||
|
MEDIA_ADMIN: 'purple',
|
||||||
|
PAYMENTS_ADMIN: 'green',
|
||||||
|
EVENTS_ADMIN: 'cyan',
|
||||||
|
SOCIAL_ADMIN: 'magenta',
|
||||||
USER: 'blue',
|
USER: 'blue',
|
||||||
TEMP: 'default',
|
TEMP: 'default',
|
||||||
};
|
};
|
||||||
@ -89,8 +95,14 @@ const statusColors: Record<UserStatus, string> = {
|
|||||||
|
|
||||||
const roleOptions: { value: UserRole; label: string }[] = [
|
const roleOptions: { value: UserRole; label: string }[] = [
|
||||||
{ value: 'SUPER_ADMIN', label: 'Super Admin' },
|
{ 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: '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: 'USER', label: 'User' },
|
||||||
{ value: 'TEMP', label: 'Temp' },
|
{ value: 'TEMP', label: 'Temp' },
|
||||||
];
|
];
|
||||||
@ -107,7 +119,7 @@ const statusOptions: { value: UserStatus; label: string }[] = [
|
|||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
const { setPageHeader } = useOutletContext<AppOutletContext>();
|
||||||
const { user: currentUser } = useAuthStore();
|
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 [nocodbUrl, setNocodbUrl] = useState<string | null>(null);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0, totalPages: 0 });
|
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;
|
if (!editingUser) return;
|
||||||
try {
|
try {
|
||||||
const payload: UpdateUserPayload = { ...values };
|
const payload: UpdateUserPayload = { ...values };
|
||||||
@ -234,6 +246,15 @@ export default function UsersPage() {
|
|||||||
payload.expiresAt = null;
|
payload.expiresAt = null;
|
||||||
}
|
}
|
||||||
delete (payload as unknown as Record<string, unknown>).expiresAtDate;
|
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
|
// Remove empty password
|
||||||
if (!payload.password) delete payload.password;
|
if (!payload.password) delete payload.password;
|
||||||
await api.put(`/users/${editingUser.id}`, payload);
|
await api.put(`/users/${editingUser.id}`, payload);
|
||||||
@ -387,6 +408,7 @@ export default function UsersPage() {
|
|||||||
status: user.status,
|
status: user.status,
|
||||||
expireDays: user.expireDays,
|
expireDays: user.expireDays,
|
||||||
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
|
expiresAtDate: user.expiresAt ? dayjs(user.expiresAt) : null,
|
||||||
|
canManageUsers: !!(user.permissions as Record<string, unknown> | undefined)?.canManageUsers,
|
||||||
});
|
});
|
||||||
setEditDrawerOpen(true);
|
setEditDrawerOpen(true);
|
||||||
fetchProvisioningStatus(user.id);
|
fetchProvisioningStatus(user.id);
|
||||||
@ -898,6 +920,16 @@ export default function UsersPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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>
|
</Form>
|
||||||
|
|
||||||
{editingUser && (
|
{editingUser && (
|
||||||
|
|||||||
@ -16,10 +16,12 @@ import {
|
|||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
EnvironmentOutlined,
|
EnvironmentOutlined,
|
||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
|
LockOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 type { SchedulingPoll, PollsListResponse, SchedulingPollStatus } from '@/types/api';
|
||||||
import { POLL_STATUS_COLORS, POLL_STATUS_LABELS } 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 screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
|
const [polls, setPolls] = useState<SchedulingPoll[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -36,7 +39,7 @@ export default function PollsListPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchPolls = async () => {
|
const fetchPolls = async () => {
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<PollsListResponse>('/api/meeting-planner/public');
|
const { data } = await api.get<PollsListResponse>('/meeting-planner/public');
|
||||||
setPolls(data.polls);
|
setPolls(data.polls);
|
||||||
} catch {
|
} catch {
|
||||||
// If unauthorized, try the public listing approach
|
// If unauthorized, try the public listing approach
|
||||||
@ -81,7 +84,10 @@ export default function PollsListPage() {
|
|||||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||||
<Row justify="space-between" align="top">
|
<Row justify="space-between" align="top">
|
||||||
<Col flex="auto">
|
<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>
|
||||||
<Col>
|
<Col>
|
||||||
<Tag color={POLL_STATUS_COLORS[poll.status as SchedulingPollStatus]}>
|
<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 }}>
|
<Button type="primary" size="small" block style={{ marginTop: 8 }}>
|
||||||
Vote Now
|
{poll.requiresAuth && !user ? 'Sign In to View' : 'Vote Now'}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -24,10 +24,11 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
SendOutlined,
|
SendOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
LockOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '@/lib/api';
|
||||||
import type {
|
import type {
|
||||||
PollDetailResponse,
|
PollDetailResponse,
|
||||||
PollVoteValue,
|
PollVoteValue,
|
||||||
@ -48,6 +49,7 @@ export default function SchedulingPollPage() {
|
|||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const screens = Grid.useBreakpoint();
|
const screens = Grid.useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
|
const navigate = useNavigate();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
|
|
||||||
const [poll, setPoll] = useState<PollDetailResponse | null>(null);
|
const [poll, setPoll] = useState<PollDetailResponse | null>(null);
|
||||||
@ -71,7 +73,7 @@ export default function SchedulingPollPage() {
|
|||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data } = await axios.get<PollDetailResponse>(`/api/meeting-planner/public/${slug}`);
|
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/public/${slug}`);
|
||||||
setPoll(data);
|
setPoll(data);
|
||||||
|
|
||||||
// Check if user has already voted (by token or auth)
|
// Check if user has already voted (by token or auth)
|
||||||
@ -123,7 +125,7 @@ export default function SchedulingPollPage() {
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
|
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(),
|
voterName: voterName.trim(),
|
||||||
voterEmail: trimmedEmail || undefined,
|
voterEmail: trimmedEmail || undefined,
|
||||||
voterToken: storedToken || undefined,
|
voterToken: storedToken || undefined,
|
||||||
@ -153,7 +155,7 @@ export default function SchedulingPollPage() {
|
|||||||
}
|
}
|
||||||
setCommentSubmitting(true);
|
setCommentSubmitting(true);
|
||||||
try {
|
try {
|
||||||
await axios.post(`/api/meeting-planner/public/${slug}/comment`, {
|
await api.post(`/meeting-planner/public/${slug}/comment`, {
|
||||||
authorName: commentName.trim(),
|
authorName: commentName.trim(),
|
||||||
content: commentContent.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 isOpen = poll.status === 'OPEN';
|
||||||
const isFinalized = poll.status === 'FINALIZED';
|
const isFinalized = poll.status === 'FINALIZED';
|
||||||
const bestScore = poll.options?.length ? Math.max(...poll.options.map((o) => o.score ?? 0)) : 0;
|
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 { api } from '@/lib/api';
|
||||||
import FeatureGate from '@/components/FeatureGate';
|
import FeatureGate from '@/components/FeatureGate';
|
||||||
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
|
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
|
||||||
import MobileDayView from '@/components/calendar/MobileDayView';
|
import CalendarTimeGrid from '@/components/calendar/CalendarTimeGrid';
|
||||||
import type { PersonalCalendarItem } from '@/types/api';
|
import type { PersonalCalendarItem } from '@/types/api';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
@ -164,14 +164,13 @@ export default function FriendCalendarPage() {
|
|||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<div>
|
<div>
|
||||||
<MobileDayView
|
<CalendarTimeGrid
|
||||||
items={items}
|
items={items}
|
||||||
currentMonth={currentMonth}
|
viewMode="day"
|
||||||
selectedDate={selectedDate}
|
currentDate={currentMonth}
|
||||||
onDateSelect={setSelectedDate}
|
onDateSelect={setSelectedDate}
|
||||||
onMonthChange={setCurrentMonth}
|
|
||||||
onAddItem={() => {}}
|
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
|
onNavigate={(dir) => setCurrentMonth((d) => d.add(dir === 'next' ? 1 : -1, 'day'))}
|
||||||
/>
|
/>
|
||||||
{dateDetailPanel}
|
{dateDetailPanel}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -23,6 +23,8 @@ import {
|
|||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
EditOutlined,
|
EditOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
LeftOutlined,
|
||||||
|
RightOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import dayjs, { Dayjs } from 'dayjs';
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@ -30,10 +32,12 @@ import { api } from '@/lib/api';
|
|||||||
import FeatureGate from '@/components/FeatureGate';
|
import FeatureGate from '@/components/FeatureGate';
|
||||||
import CalendarLayerPanel from '@/components/calendar/CalendarLayerPanel';
|
import CalendarLayerPanel from '@/components/calendar/CalendarLayerPanel';
|
||||||
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
|
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 CalendarItemModal, { type CalendarItemFormData } from '@/components/calendar/CalendarItemModal';
|
||||||
|
import CalendarItemDetail from '@/components/calendar/CalendarItemDetail';
|
||||||
import CalendarFeedsPanel from '@/components/calendar/CalendarFeedsPanel';
|
import CalendarFeedsPanel from '@/components/calendar/CalendarFeedsPanel';
|
||||||
import CalendarExportPanel from '@/components/calendar/CalendarExportPanel';
|
import CalendarExportPanel from '@/components/calendar/CalendarExportPanel';
|
||||||
|
import { getDateRangeForView, type CalendarViewMode } from '@/components/calendar/calendarUtils';
|
||||||
import type {
|
import type {
|
||||||
CalendarLayer,
|
CalendarLayer,
|
||||||
PersonalCalendarItem,
|
PersonalCalendarItem,
|
||||||
@ -55,12 +59,14 @@ export default function MyCalendarPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// View state
|
// 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);
|
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||||
|
|
||||||
// Modal state
|
// Modal / detail state
|
||||||
const [itemModalOpen, setItemModalOpen] = useState(false);
|
const [itemModalOpen, setItemModalOpen] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState<PersonalCalendarItem | null>(null);
|
const [editingItem, setEditingItem] = useState<PersonalCalendarItem | null>(null);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<PersonalCalendarItem | null>(null);
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
|
||||||
// Derived: enabled layer IDs for filtering
|
// Derived: enabled layer IDs for filtering
|
||||||
@ -69,7 +75,7 @@ export default function MyCalendarPage() {
|
|||||||
// Filtered items based on enabled layers
|
// Filtered items based on enabled layers
|
||||||
const filteredItems = items.filter((item) => enabledLayerIds.has(item.layerId));
|
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
|
const selectedDateItems = selectedDate
|
||||||
? filteredItems.filter((item) => item.date === selectedDate)
|
? filteredItems.filter((item) => item.date === selectedDate)
|
||||||
: [];
|
: [];
|
||||||
@ -86,13 +92,11 @@ export default function MyCalendarPage() {
|
|||||||
|
|
||||||
// Fetch items for the current visible range
|
// Fetch items for the current visible range
|
||||||
const fetchItems = useCallback(async () => {
|
const fetchItems = useCallback(async () => {
|
||||||
const startDate = currentMonth.startOf('month').subtract(7, 'day').format('YYYY-MM-DD');
|
const { startDate, endDate } = getDateRangeForView(viewMode, currentDate);
|
||||||
const endDate = currentMonth.endOf('month').add(7, 'day').format('YYYY-MM-DD');
|
|
||||||
try {
|
try {
|
||||||
const { data } = await api.get<PersonalCalendarResponse>('/calendar/my', {
|
const { data } = await api.get<PersonalCalendarResponse>('/calendar/my', {
|
||||||
params: { startDate, endDate },
|
params: { startDate, endDate },
|
||||||
});
|
});
|
||||||
// Flatten the dates map into a flat item array
|
|
||||||
const allItems: PersonalCalendarItem[] = [];
|
const allItems: PersonalCalendarItem[] = [];
|
||||||
for (const dateGroup of Object.values(data.dates)) {
|
for (const dateGroup of Object.values(data.dates)) {
|
||||||
allItems.push(...dateGroup.items);
|
allItems.push(...dateGroup.items);
|
||||||
@ -101,7 +105,7 @@ export default function MyCalendarPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
// Empty calendar
|
// Empty calendar
|
||||||
}
|
}
|
||||||
}, [currentMonth]);
|
}, [viewMode, currentDate]);
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -127,7 +131,6 @@ export default function MyCalendarPage() {
|
|||||||
const handleUpdateLayer = async (id: string, updates: Partial<CalendarLayer>) => {
|
const handleUpdateLayer = async (id: string, updates: Partial<CalendarLayer>) => {
|
||||||
try {
|
try {
|
||||||
await api.patch(`/calendar/layers/${id}`, updates);
|
await api.patch(`/calendar/layers/${id}`, updates);
|
||||||
// Optimistic update for toggle
|
|
||||||
setLayers((prev) =>
|
setLayers((prev) =>
|
||||||
prev.map((l) => (l.id === id ? { ...l, ...updates } : l)),
|
prev.map((l) => (l.id === id ? { ...l, ...updates } : l)),
|
||||||
);
|
);
|
||||||
@ -220,9 +223,14 @@ export default function MyCalendarPage() {
|
|||||||
setItemModalOpen(true);
|
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) => {
|
const handleEditItem = (item: PersonalCalendarItem) => {
|
||||||
if (item.type !== 'personal') return; // Only personal items are editable
|
if (item.type !== 'personal') return;
|
||||||
setEditingItem(item);
|
setEditingItem(item);
|
||||||
setItemModalOpen(true);
|
setItemModalOpen(true);
|
||||||
};
|
};
|
||||||
@ -230,11 +238,54 @@ export default function MyCalendarPage() {
|
|||||||
// Date click handler
|
// Date click handler
|
||||||
const handleDateSelect = (date: string) => {
|
const handleDateSelect = (date: string) => {
|
||||||
setSelectedDate(date);
|
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
|
// Navigation
|
||||||
const handleMonthChange = (month: Dayjs) => {
|
const handleNavigate = (direction: 'prev' | 'next') => {
|
||||||
setCurrentMonth(month);
|
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) {
|
if (loading) {
|
||||||
@ -245,8 +296,8 @@ export default function MyCalendarPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date detail panel (right side on desktop, or inline on mobile)
|
// Date detail panel (right side on desktop, used only in month view)
|
||||||
const dateDetailPanel = selectedDate && (
|
const dateDetailPanel = viewMode === 'month' && selectedDate && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: isMobile ? '100%' : 280,
|
width: isMobile ? '100%' : 280,
|
||||||
@ -277,8 +328,8 @@ export default function MyCalendarPage() {
|
|||||||
dataSource={selectedDateItems}
|
dataSource={selectedDateItems}
|
||||||
renderItem={(item) => (
|
renderItem={(item) => (
|
||||||
<List.Item
|
<List.Item
|
||||||
style={{ padding: '8px 0', cursor: item.type === 'personal' ? 'pointer' : 'default' }}
|
style={{ padding: '8px 0', cursor: 'pointer' }}
|
||||||
onClick={() => handleEditItem(item)}
|
onClick={() => handleItemClick(item)}
|
||||||
actions={
|
actions={
|
||||||
item.type === 'personal'
|
item.type === 'personal'
|
||||||
? [
|
? [
|
||||||
@ -361,13 +412,13 @@ export default function MyCalendarPage() {
|
|||||||
return (
|
return (
|
||||||
<FeatureGate feature="enableSocialCalendar">
|
<FeatureGate feature="enableSocialCalendar">
|
||||||
<div style={{ padding: '12px 0' }}>
|
<div style={{ padding: '12px 0' }}>
|
||||||
{/* Header */}
|
{/* Header row 1: Tab selector + settings/add */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 16,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Space>
|
<Space>
|
||||||
@ -395,54 +446,155 @@ export default function MyCalendarPage() {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile ? (
|
{/* Header row 2: Navigation + view mode */}
|
||||||
/* Mobile layout: MobileDayView with layer toggles at top */
|
<div
|
||||||
<div>
|
style={{
|
||||||
<CalendarLayerPanel
|
display: 'flex',
|
||||||
layers={layers}
|
alignItems: 'center',
|
||||||
compact
|
justifyContent: 'space-between',
|
||||||
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
|
marginBottom: 12,
|
||||||
onCreate={handleCreateLayer}
|
flexWrap: 'wrap',
|
||||||
onUpdate={handleUpdateLayer}
|
gap: 8,
|
||||||
onDelete={handleDeleteLayer}
|
}}
|
||||||
|
>
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<LeftOutlined />}
|
||||||
|
size="small"
|
||||||
|
onClick={() => handleNavigate('prev')}
|
||||||
/>
|
/>
|
||||||
<MobileDayView
|
<Button size="small" onClick={handleToday}>
|
||||||
items={filteredItems}
|
Today
|
||||||
currentMonth={currentMonth}
|
</Button>
|
||||||
selectedDate={selectedDate}
|
<Button
|
||||||
onDateSelect={handleDateSelect}
|
icon={<RightOutlined />}
|
||||||
onMonthChange={handleMonthChange}
|
size="small"
|
||||||
onItemClick={handleEditItem}
|
onClick={() => handleNavigate('next')}
|
||||||
onAddItem={handleAddItem}
|
|
||||||
/>
|
/>
|
||||||
{dateDetailPanel}
|
<Text strong style={{ fontSize: 15, marginLeft: 4 }}>
|
||||||
</div>
|
{getNavLabel()}
|
||||||
) : (
|
</Text>
|
||||||
/* Desktop layout: layer panel | calendar | date detail */
|
</Space>
|
||||||
<div style={{ display: 'flex', gap: 0 }}>
|
<Segmented
|
||||||
<div style={{ width: 240, flexShrink: 0, paddingRight: 16 }}>
|
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
|
<CalendarLayerPanel
|
||||||
layers={layers}
|
layers={layers}
|
||||||
|
compact
|
||||||
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
|
onToggle={(id, enabled) => handleUpdateLayer(id, { isEnabled: enabled })}
|
||||||
onCreate={handleCreateLayer}
|
onCreate={handleCreateLayer}
|
||||||
onUpdate={handleUpdateLayer}
|
onUpdate={handleUpdateLayer}
|
||||||
onDelete={handleDeleteLayer}
|
onDelete={handleDeleteLayer}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<PersonalCalendarView
|
<PersonalCalendarView
|
||||||
items={filteredItems}
|
items={filteredItems}
|
||||||
currentMonth={currentMonth}
|
currentMonth={currentDate}
|
||||||
selectedDate={selectedDate}
|
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}
|
onDateSelect={handleDateSelect}
|
||||||
onMonthChange={handleMonthChange}
|
onItemClick={handleItemClick}
|
||||||
onItemClick={handleEditItem}
|
onNavigate={handleNavigate}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Settings drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
title="Calendar Settings"
|
title="Calendar Settings"
|
||||||
|
|||||||
@ -25,7 +25,7 @@ import { api } from '@/lib/api';
|
|||||||
import { useAuthStore } from '@/stores/auth.store';
|
import { useAuthStore } from '@/stores/auth.store';
|
||||||
import FeatureGate from '@/components/FeatureGate';
|
import FeatureGate from '@/components/FeatureGate';
|
||||||
import PersonalCalendarView from '@/components/calendar/PersonalCalendarView';
|
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 SharedViewMembersPanel from '@/components/calendar/SharedViewMembersPanel';
|
||||||
import AvailabilityFinder from '@/components/calendar/AvailabilityFinder';
|
import AvailabilityFinder from '@/components/calendar/AvailabilityFinder';
|
||||||
import CalendarComments from '@/components/calendar/CalendarComments';
|
import CalendarComments from '@/components/calendar/CalendarComments';
|
||||||
@ -258,14 +258,13 @@ export default function SharedCalendarViewPage() {
|
|||||||
label: 'Calendar',
|
label: 'Calendar',
|
||||||
children: (
|
children: (
|
||||||
<>
|
<>
|
||||||
<MobileDayView
|
<CalendarTimeGrid
|
||||||
items={items}
|
items={items}
|
||||||
currentMonth={currentMonth}
|
viewMode="day"
|
||||||
selectedDate={selectedDate}
|
currentDate={currentMonth}
|
||||||
onDateSelect={setSelectedDate}
|
onDateSelect={setSelectedDate}
|
||||||
onMonthChange={setCurrentMonth}
|
|
||||||
onAddItem={() => {}}
|
|
||||||
onItemClick={handleItemClick}
|
onItemClick={handleItemClick}
|
||||||
|
onNavigate={(dir) => setCurrentMonth((d) => d.add(dir === 'next' ? 1 : -1, 'day'))}
|
||||||
/>
|
/>
|
||||||
{dateDetailPanel}
|
{dateDetailPanel}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export interface AppOutletContext {
|
|||||||
setPageHeader: (config: PageHeaderConfig | null) => void;
|
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';
|
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
|
||||||
|
|
||||||
@ -87,6 +87,7 @@ export interface UpdateUserPayload {
|
|||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
expiresAt?: string | null;
|
expiresAt?: string | null;
|
||||||
expireDays?: number;
|
expireDays?: number;
|
||||||
|
permissions?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersListParams {
|
export interface UsersListParams {
|
||||||
@ -97,7 +98,19 @@ export interface UsersListParams {
|
|||||||
status?: UserStatus;
|
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 ---
|
// --- User Provisioning ---
|
||||||
|
|
||||||
@ -783,7 +796,7 @@ export const CUT_CATEGORY_COLORS: Record<CutCategory, string> = {
|
|||||||
DISTRICT: 'purple',
|
DISTRICT: 'purple',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MAP_ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN'];
|
export const MAP_ADMIN_ROLES: UserRole[] = MAP_ROLES;
|
||||||
|
|
||||||
// --- Map / Shifts ---
|
// --- Map / Shifts ---
|
||||||
|
|
||||||
@ -1153,6 +1166,7 @@ export interface SiteSettings {
|
|||||||
enableMeetingPlanner: boolean;
|
enableMeetingPlanner: boolean;
|
||||||
enableTicketedEvents: boolean;
|
enableTicketedEvents: boolean;
|
||||||
enableSocialCalendar: boolean;
|
enableSocialCalendar: boolean;
|
||||||
|
enableDocsCollaboration: boolean;
|
||||||
requireEventApproval: boolean;
|
requireEventApproval: boolean;
|
||||||
autoSyncPeopleToMap: boolean;
|
autoSyncPeopleToMap: boolean;
|
||||||
// SMS connection config (only present from admin endpoint)
|
// SMS connection config (only present from admin endpoint)
|
||||||
@ -1162,6 +1176,11 @@ export interface SiteSettings {
|
|||||||
smsTailscaleTailnet?: string;
|
smsTailscaleTailnet?: string;
|
||||||
smsTailscaleDeviceId?: string;
|
smsTailscaleDeviceId?: string;
|
||||||
smsTailscaleDeviceName?: 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
|
// Navigation configuration
|
||||||
navConfig: NavConfig | null;
|
navConfig: NavConfig | null;
|
||||||
// User Provisioning
|
// User Provisioning
|
||||||
@ -2895,7 +2914,9 @@ export interface SchedulingPoll {
|
|||||||
convertedGancioEventId: number | null;
|
convertedGancioEventId: number | null;
|
||||||
votingDeadline: string | null;
|
votingDeadline: string | null;
|
||||||
allowAnonymous: boolean;
|
allowAnonymous: boolean;
|
||||||
|
isPrivate: boolean;
|
||||||
notifyOnVote: boolean;
|
notifyOnVote: boolean;
|
||||||
|
requiresAuth?: boolean;
|
||||||
createdByUserId: string;
|
createdByUserId: string;
|
||||||
createdBy?: { id: string; name: string | null; email: string };
|
createdBy?: { id: string; name: string | null; email: string };
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -2961,6 +2982,11 @@ export interface UpgradeResult {
|
|||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
|
triggeredBy?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpgradeHistoryResponse {
|
||||||
|
history: UpgradeResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpgradeStatusResponse {
|
export interface UpgradeStatusResponse {
|
||||||
|
|||||||
@ -26,6 +26,7 @@ export default defineConfig({
|
|||||||
// Use env var with fallback: Docker uses container name, local uses localhost
|
// Use env var with fallback: Docker uses container name, local uses localhost
|
||||||
target: process.env.VITE_API_URL || 'http://localhost:4000',
|
target: process.env.VITE_API_URL || 'http://localhost:4000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
ws: true, // WebSocket passthrough for docs collaboration
|
||||||
},
|
},
|
||||||
'/media/public': {
|
'/media/public': {
|
||||||
// Public media routes: rewrite to /api/public (matches Fastify prefix: '/api')
|
// 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/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@hocuspocus/server": "^3.4.4",
|
||||||
"@prisma/client": "^6.3.0",
|
"@prisma/client": "^6.3.0",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@ -30,10 +31,10 @@
|
|||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mime-types": "^3.0.2",
|
"mime-types": "^3.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.1.1",
|
||||||
"node-addon-api": "^8.5.0",
|
"node-addon-api": "^8.5.0",
|
||||||
"node-ical": "^0.25.5",
|
"node-ical": "^0.25.5",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"proj4": "^2.20.2",
|
"proj4": "^2.20.2",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
@ -42,7 +43,9 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^20.3.1",
|
"stripe": "^20.3.1",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
|
"ws": "^8.19.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
"yjs": "^13.6.29",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -53,9 +56,10 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.19.11",
|
"@types/node": "^22.19.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"prisma": "^6.3.0",
|
"prisma": "^6.3.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
@ -1179,6 +1183,31 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"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": {
|
"node_modules/@img/colour": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz",
|
||||||
@ -1943,9 +1972,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/nodemailer": {
|
"node_modules/@types/nodemailer": {
|
||||||
"version": "6.4.22",
|
"version": "7.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.22.tgz",
|
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
|
||||||
"integrity": "sha512-HV16KRsW7UyZBITE07B62k8PRAKFqRSFXn1T7vslurVjN761tMDBhk5Lbt17ehyTzK6XcyJnAgUpevrvkcVOzw==",
|
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
@ -2007,6 +2036,15 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz",
|
||||||
"integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw=="
|
"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": {
|
"node_modules/abstract-logging": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="
|
"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": {
|
"node_modules/atomic-sleep": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
@ -3969,6 +4020,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/jackspeak": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
||||||
@ -4065,11 +4125,39 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"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": {
|
"node_modules/kuler": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz",
|
||||||
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A=="
|
"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": {
|
"node_modules/light-my-request": {
|
||||||
"version": "6.6.0",
|
"version": "6.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
|
"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"
|
"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": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.2",
|
"version": "7.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
|
||||||
@ -4311,17 +4391,6 @@
|
|||||||
"node": ">=16 || 14 >=14.17"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@ -4357,21 +4426,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.0.2",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
|
||||||
"integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==",
|
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.6.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^2.0.0",
|
"concat-stream": "^2.0.0",
|
||||||
"mkdirp": "^0.5.6",
|
"type-is": "^1.6.18"
|
||||||
"object-assign": "^4.1.1",
|
|
||||||
"type-is": "^1.6.18",
|
|
||||||
"xtend": "^4.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 10.16.0"
|
"node": ">= 10.16.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
@ -4428,9 +4497,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "6.10.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
|
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
@ -5655,6 +5724,26 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
@ -5663,6 +5752,26 @@
|
|||||||
"node": ">=0.4"
|
"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": {
|
"node_modules/y18n": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
@ -5716,6 +5825,22 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "3.25.76",
|
"version": "3.25.76",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
"@fastify/multipart": "^9.4.0",
|
"@fastify/multipart": "^9.4.0",
|
||||||
"@fastify/static": "^9.0.0",
|
"@fastify/static": "^9.0.0",
|
||||||
|
"@hocuspocus/server": "^3.4.4",
|
||||||
"@prisma/client": "^6.3.0",
|
"@prisma/client": "^6.3.0",
|
||||||
"@types/mime-types": "^3.0.1",
|
"@types/mime-types": "^3.0.1",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
@ -38,10 +39,10 @@
|
|||||||
"ioredis": "^5.4.2",
|
"ioredis": "^5.4.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mime-types": "^3.0.2",
|
"mime-types": "^3.0.2",
|
||||||
"multer": "^2.0.2",
|
"multer": "^2.1.1",
|
||||||
"node-addon-api": "^8.5.0",
|
"node-addon-api": "^8.5.0",
|
||||||
"node-ical": "^0.25.5",
|
"node-ical": "^0.25.5",
|
||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^8.0.1",
|
||||||
"pg": "^8.18.0",
|
"pg": "^8.18.0",
|
||||||
"proj4": "^2.20.2",
|
"proj4": "^2.20.2",
|
||||||
"prom-client": "^15.1.3",
|
"prom-client": "^15.1.3",
|
||||||
@ -50,7 +51,9 @@
|
|||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"stripe": "^20.3.1",
|
"stripe": "^20.3.1",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.17.0",
|
||||||
|
"ws": "^8.19.0",
|
||||||
"yaml": "^2.8.2",
|
"yaml": "^2.8.2",
|
||||||
|
"yjs": "^13.6.29",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -61,9 +64,10 @@
|
|||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/multer": "^2.0.0",
|
"@types/multer": "^2.0.0",
|
||||||
"@types/node": "^22.19.11",
|
"@types/node": "^22.19.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^7.0.11",
|
||||||
"@types/pg": "^8.16.0",
|
"@types/pg": "^8.16.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
"drizzle-kit": "^0.31.9",
|
"drizzle-kit": "^0.31.9",
|
||||||
"prisma": "^6.3.0",
|
"prisma": "^6.3.0",
|
||||||
"tsx": "^4.19.2",
|
"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
|
SUPER_ADMIN
|
||||||
INFLUENCE_ADMIN
|
INFLUENCE_ADMIN
|
||||||
MAP_ADMIN
|
MAP_ADMIN
|
||||||
|
BROADCAST_ADMIN
|
||||||
|
CONTENT_ADMIN
|
||||||
|
MEDIA_ADMIN
|
||||||
|
PAYMENTS_ADMIN
|
||||||
|
EVENTS_ADMIN
|
||||||
|
SOCIAL_ADMIN
|
||||||
USER
|
USER
|
||||||
TEMP
|
TEMP
|
||||||
}
|
}
|
||||||
@ -935,6 +941,7 @@ model SiteSettings {
|
|||||||
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
||||||
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
|
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
|
||||||
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
|
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
|
||||||
|
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
|
||||||
requireEventApproval Boolean @default(true) @map("require_event_approval")
|
requireEventApproval Boolean @default(true) @map("require_event_approval")
|
||||||
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
||||||
|
|
||||||
@ -974,6 +981,12 @@ model SiteSettings {
|
|||||||
reengagementInactiveDays Int @default(30) @map("reengagement_inactive_days")
|
reengagementInactiveDays Int @default(30) @map("reengagement_inactive_days")
|
||||||
reengagementCooldownDays Int @default(30) @map("reengagement_cooldown_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[] })
|
// Navigation configuration (JSON: { items: NavItem[] })
|
||||||
navConfig Json? @map("nav_config")
|
navConfig Json? @map("nav_config")
|
||||||
|
|
||||||
@ -4383,6 +4396,7 @@ model SchedulingPoll {
|
|||||||
convertedGancioEventId Int? @map("converted_gancio_event_id")
|
convertedGancioEventId Int? @map("converted_gancio_event_id")
|
||||||
votingDeadline DateTime? @map("voting_deadline")
|
votingDeadline DateTime? @map("voting_deadline")
|
||||||
allowAnonymous Boolean @default(true) @map("allow_anonymous")
|
allowAnonymous Boolean @default(true) @map("allow_anonymous")
|
||||||
|
isPrivate Boolean @default(false) @map("is_private")
|
||||||
notifyOnVote Boolean @default(true) @map("notify_on_vote")
|
notifyOnVote Boolean @default(true) @map("notify_on_vote")
|
||||||
createdByUserId String @map("created_by_user_id")
|
createdByUserId String @map("created_by_user_id")
|
||||||
createdBy User @relation("PollCreator", fields: [createdByUserId], references: [id])
|
createdBy User @relation("PollCreator", fields: [createdByUserId], references: [id])
|
||||||
@ -5093,3 +5107,17 @@ model CalendarExportToken {
|
|||||||
@@index([userId], map: "idx_calendar_export_tokens_user")
|
@@index([userId], map: "idx_calendar_export_tokens_user")
|
||||||
@@map("calendar_export_tokens")
|
@@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)
|
// Check multi-role array (falls back to single role via auth middleware)
|
||||||
const userRoles = req.user.roles || [req.user.role];
|
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));
|
const hasRole = userRoles.some(r => roles.includes(r));
|
||||||
|
|
||||||
if (!hasRole) {
|
if (!hasRole) {
|
||||||
|
|||||||
@ -215,6 +215,18 @@ export const authService = {
|
|||||||
throw new AppError(401, 'Refresh token not found', 'INVALID_REFRESH_TOKEN');
|
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()) {
|
if (stored.expiresAt < new Date()) {
|
||||||
await prisma.refreshToken.delete({ where: { id: stored.id } });
|
await prisma.refreshToken.delete({ where: { id: stored.id } });
|
||||||
throw new AppError(401, 'Refresh token expired', 'REFRESH_TOKEN_EXPIRED');
|
throw new AppError(401, 'Refresh token expired', 'REFRESH_TOKEN_EXPIRED');
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { EVENTS_ROLES } from '../../utils/roles';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { adminCalendarService } from './admin-calendar.service';
|
import { adminCalendarService } from './admin-calendar.service';
|
||||||
import { createAdminViewSchema, updateAdminViewSchema } from './admin-calendar.schemas';
|
import { createAdminViewSchema, updateAdminViewSchema } from './admin-calendar.schemas';
|
||||||
@ -9,7 +10,7 @@ import { dateRangeQuerySchema } from './shared-calendar.schemas';
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN'));
|
router.use(requireRole(...EVENTS_ROLES));
|
||||||
|
|
||||||
// List admin calendar views
|
// List admin calendar views
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import dns from 'dns/promises';
|
||||||
|
import { URL } from 'url';
|
||||||
import {
|
import {
|
||||||
CalendarLayerType,
|
CalendarLayerType,
|
||||||
CalendarVisibility,
|
CalendarVisibility,
|
||||||
@ -20,6 +22,71 @@ const FETCH_TIMEOUT_MS = 30_000;
|
|||||||
const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
const FETCH_MAX_BYTES = 5 * 1024 * 1024; // 5MB
|
||||||
const MATERIALIZE_MONTHS = 3;
|
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
|
// Map CalendarFeedInterval to milliseconds
|
||||||
const INTERVAL_MS: Record<CalendarFeedInterval, number> = {
|
const INTERVAL_MS: Record<CalendarFeedInterval, number> = {
|
||||||
FIFTEEN_MIN: 15 * 60 * 1000,
|
FIFTEEN_MIN: 15 * 60 * 1000,
|
||||||
@ -42,6 +109,9 @@ export const feedService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async createFeed(userId: string, data: CreateFeedInput) {
|
async createFeed(userId: string, data: CreateFeedInput) {
|
||||||
|
// SSRF protection: validate URL before making any request
|
||||||
|
await validateFeedUrl(data.url);
|
||||||
|
|
||||||
// Validate URL is reachable
|
// Validate URL is reachable
|
||||||
try {
|
try {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
@ -49,10 +119,10 @@ export const feedService = {
|
|||||||
const res = await fetch(data.url, {
|
const res = await fetch(data.url, {
|
||||||
method: 'HEAD',
|
method: 'HEAD',
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: 'follow',
|
redirect: 'manual', // Don't follow redirects (prevent SSRF via open redirects)
|
||||||
});
|
});
|
||||||
clearTimeout(timeout);
|
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');
|
throw new AppError(400, `Feed URL returned status ${res.status}`, 'FEED_URL_UNREACHABLE');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -114,7 +184,11 @@ export const feedService = {
|
|||||||
data: { name: data.name },
|
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) {
|
if (data.refreshInterval !== undefined) {
|
||||||
updateData.refreshInterval = data.refreshInterval as CalendarFeedInterval;
|
updateData.refreshInterval = data.refreshInterval as CalendarFeedInterval;
|
||||||
}
|
}
|
||||||
@ -148,9 +222,12 @@ export const feedService = {
|
|||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
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, {
|
const response = await fetch(feed.url, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
redirect: 'follow',
|
redirect: 'manual', // Don't follow redirects (SSRF protection)
|
||||||
headers: { 'User-Agent': 'Changemaker-Calendar/1.0' },
|
headers: { 'User-Agent': 'Changemaker-Calendar/1.0' },
|
||||||
});
|
});
|
||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { ADMIN_ROLES } from '../../utils/roles';
|
||||||
import {
|
import {
|
||||||
getDashboardSummary,
|
getDashboardSummary,
|
||||||
getSystemInfo,
|
getSystemInfo,
|
||||||
@ -25,7 +26,7 @@ import {
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
|
router.use(requireRole(...ADMIN_ROLES));
|
||||||
|
|
||||||
// GET /api/dashboard/summary — platform counts
|
// GET /api/dashboard/summary — platform counts
|
||||||
router.get('/summary', async (_req: Request, res: Response, next: NextFunction) => {
|
router.get('/summary', async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { docsAnalyticsRateLimit } from '../../middleware/rate-limit';
|
import { docsAnalyticsRateLimit } from '../../middleware/rate-limit';
|
||||||
import { docsAnalyticsService } from './docs-analytics.service';
|
import { docsAnalyticsService } from './docs-analytics.service';
|
||||||
import { trackPageViewSchema, analyticsQuerySchema } from './docs-analytics.schemas';
|
import { trackPageViewSchema, analyticsQuerySchema } from './docs-analytics.schemas';
|
||||||
|
import { CONTENT_ROLES } from '../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// --- Public Router (no auth) ---
|
// --- Public Router (no auth) ---
|
||||||
export const docsAnalyticsPublicRouter = Router();
|
export const docsAnalyticsPublicRouter = Router();
|
||||||
@ -47,7 +45,7 @@ docsAnalyticsPublicRouter.post(
|
|||||||
// --- Admin Router (auth required) ---
|
// --- Admin Router (auth required) ---
|
||||||
export const docsAnalyticsAdminRouter = Router();
|
export const docsAnalyticsAdminRouter = Router();
|
||||||
docsAnalyticsAdminRouter.use(authenticate);
|
docsAnalyticsAdminRouter.use(authenticate);
|
||||||
docsAnalyticsAdminRouter.use(requireRole(...ADMIN_ROLES));
|
docsAnalyticsAdminRouter.use(requireRole(...CONTENT_ROLES));
|
||||||
|
|
||||||
// GET /api/docs-analytics/summary?days=30
|
// GET /api/docs-analytics/summary?days=30
|
||||||
docsAnalyticsAdminRouter.get(
|
docsAnalyticsAdminRouter.get(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
@ -19,8 +18,7 @@ import {
|
|||||||
moderationQuerySchema,
|
moderationQuerySchema,
|
||||||
} from './docs-comments.schemas';
|
} from './docs-comments.schemas';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
|
import { CONTENT_ROLES } from '../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// --- Public Router (CORS override for docs origin) ---
|
// --- Public Router (CORS override for docs origin) ---
|
||||||
export const docsCommentsPublicRouter = Router();
|
export const docsCommentsPublicRouter = Router();
|
||||||
@ -195,7 +193,7 @@ docsCommentsPublicRouter.get('/oauth/config', async (_req, res) => {
|
|||||||
// --- Admin Router (auth required) ---
|
// --- Admin Router (auth required) ---
|
||||||
export const docsCommentsAdminRouter = Router();
|
export const docsCommentsAdminRouter = Router();
|
||||||
docsCommentsAdminRouter.use(authenticate);
|
docsCommentsAdminRouter.use(authenticate);
|
||||||
docsCommentsAdminRouter.use(requireRole(...ADMIN_ROLES));
|
docsCommentsAdminRouter.use(requireRole(...CONTENT_ROLES));
|
||||||
|
|
||||||
// GET /api/docs-comments/moderation?status=PENDING&page=1
|
// GET /api/docs-comments/moderation?status=PENDING&page=1
|
||||||
docsCommentsAdminRouter.get(
|
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 { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
|
import { CONTENT_ROLES } from '../../utils/roles';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { isServiceOnline } from '../../utils/health-check';
|
import { isServiceOnline } from '../../utils/health-check';
|
||||||
import { cm_docs_operations } from '../../utils/metrics';
|
import { cm_docs_operations } from '../../utils/metrics';
|
||||||
import { docsFilesService, PathTraversalError, FileNotFoundError } from './docs-files.service';
|
import { docsFilesService, PathTraversalError, FileNotFoundError } from './docs-files.service';
|
||||||
|
import { docsCollabService } from './docs-collab.service';
|
||||||
import { mkdocsConfigService } from './mkdocs-config.service';
|
import { mkdocsConfigService } from './mkdocs-config.service';
|
||||||
import { headerBuilderService } from './header-builder.service';
|
import { headerBuilderService } from './header-builder.service';
|
||||||
import { headerConfigSchema } from './header-builder.schemas';
|
import { headerConfigSchema } from './header-builder.schemas';
|
||||||
@ -73,7 +75,7 @@ router.get(
|
|||||||
// PUT /api/docs/mkdocs-config — validate + write mkdocs.yml (SUPER_ADMIN only)
|
// PUT /api/docs/mkdocs-config — validate + write mkdocs.yml (SUPER_ADMIN only)
|
||||||
router.put(
|
router.put(
|
||||||
'/mkdocs-config',
|
'/mkdocs-config',
|
||||||
requireRole('SUPER_ADMIN'),
|
requireRole(...CONTENT_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { content } = req.body as { content?: string };
|
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(
|
router.post(
|
||||||
'/build',
|
'/build',
|
||||||
requireRole('SUPER_ADMIN'),
|
requireRole(...CONTENT_ROLES),
|
||||||
async (_req: Request, res: Response, next: NextFunction) => {
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await mkdocsConfigService.triggerBuild();
|
const result = await mkdocsConfigService.triggerBuild();
|
||||||
@ -128,7 +130,7 @@ router.get(
|
|||||||
// PUT /api/docs/header-config — save header nav bar config + regenerate template
|
// PUT /api/docs/header-config — save header nav bar config + regenerate template
|
||||||
router.put(
|
router.put(
|
||||||
'/header-config',
|
'/header-config',
|
||||||
requireRole('SUPER_ADMIN'),
|
requireRole(...CONTENT_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const parsed = headerConfigSchema.safeParse(req.body);
|
const parsed = headerConfigSchema.safeParse(req.body);
|
||||||
@ -172,7 +174,7 @@ const upload = multer({
|
|||||||
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
|
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
|
||||||
router.post(
|
router.post(
|
||||||
'/upload',
|
'/upload',
|
||||||
requireRole('SUPER_ADMIN'),
|
requireRole(...CONTENT_ROLES),
|
||||||
upload.single('file'),
|
upload.single('file'),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
const tempPath = req.file?.path;
|
const tempPath = req.file?.path;
|
||||||
@ -244,7 +246,7 @@ router.get(
|
|||||||
// POST /api/docs/files/rename — rename/move file
|
// POST /api/docs/files/rename — rename/move file
|
||||||
router.post(
|
router.post(
|
||||||
'/files/rename',
|
'/files/rename',
|
||||||
requireRole('SUPER_ADMIN'),
|
requireRole(...CONTENT_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'rename' });
|
cm_docs_operations.inc({ operation: 'rename' });
|
||||||
@ -254,6 +256,8 @@ router.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await docsFilesService.renameFile(from, to);
|
await docsFilesService.renameFile(from, to);
|
||||||
|
// Invalidate old path's collaboration state
|
||||||
|
docsCollabService.invalidateDocument(from).catch(() => {});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleFileError(err, res, next);
|
handleFileError(err, res, next);
|
||||||
@ -283,7 +287,7 @@ router.get(
|
|||||||
// PUT /api/docs/files/* — write/update file content
|
// PUT /api/docs/files/* — write/update file content
|
||||||
router.put(
|
router.put(
|
||||||
'/files/*',
|
'/files/*',
|
||||||
requireRole('SUPER_ADMIN'),
|
requireRole(...CONTENT_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'write' });
|
cm_docs_operations.inc({ operation: 'write' });
|
||||||
@ -298,6 +302,8 @@ router.put(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await docsFilesService.writeFileContent(filePath, content);
|
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 });
|
res.json({ success: true, path: filePath });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleFileError(err, res, next);
|
handleFileError(err, res, next);
|
||||||
@ -308,7 +314,7 @@ router.put(
|
|||||||
// POST /api/docs/files/* — create new file or folder
|
// POST /api/docs/files/* — create new file or folder
|
||||||
router.post(
|
router.post(
|
||||||
'/files/*',
|
'/files/*',
|
||||||
requireRole('SUPER_ADMIN'),
|
requireRole(...CONTENT_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'create' });
|
cm_docs_operations.inc({ operation: 'create' });
|
||||||
@ -329,7 +335,7 @@ router.post(
|
|||||||
// DELETE /api/docs/files/* — delete file or empty folder
|
// DELETE /api/docs/files/* — delete file or empty folder
|
||||||
router.delete(
|
router.delete(
|
||||||
'/files/*',
|
'/files/*',
|
||||||
requireRole('SUPER_ADMIN'),
|
requireRole(...CONTENT_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
cm_docs_operations.inc({ operation: 'delete' });
|
cm_docs_operations.inc({ operation: 'delete' });
|
||||||
@ -339,6 +345,8 @@ router.delete(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await docsFilesService.deleteFile(filePath);
|
await docsFilesService.deleteFile(filePath);
|
||||||
|
// Invalidate collaboration state for deleted file
|
||||||
|
docsCollabService.invalidateDocument(filePath).catch(() => {});
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleFileError(err, res, next);
|
handleFileError(err, res, next);
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { authenticate } from '../../middleware/auth.middleware';
|
|||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from '@prisma/client';
|
||||||
import { Request, Response } from 'express';
|
import { Request, Response } from 'express';
|
||||||
|
import { BROADCAST_ROLES } from '../../utils/roles';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import RedisStore from 'rate-limit-redis';
|
import RedisStore from 'rate-limit-redis';
|
||||||
import { redis } from '../../config/redis';
|
import { redis } from '../../config/redis';
|
||||||
@ -24,8 +25,8 @@ const router = Router();
|
|||||||
// All email template routes require authentication
|
// All email template routes require authentication
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
// All routes require admin role (SUPER_ADMIN, INFLUENCE_ADMIN, or MAP_ADMIN)
|
// All routes require broadcast admin role
|
||||||
const requireAdminRole = requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN);
|
const requireBroadcastRole = requireRole(...BROADCAST_ROLES);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List email templates
|
* List email templates
|
||||||
@ -33,7 +34,7 @@ const requireAdminRole = requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_AD
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
validate(listEmailTemplatesSchema, 'query'),
|
validate(listEmailTemplatesSchema, 'query'),
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -52,7 +53,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/:id',
|
'/:id',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const template = await emailTemplatesService.getById(req.params.id as string);
|
const template = await emailTemplatesService.getById(req.params.id as string);
|
||||||
@ -74,7 +75,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
validate(createEmailTemplateSchema),
|
validate(createEmailTemplateSchema),
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -101,7 +102,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.put(
|
router.put(
|
||||||
'/:id',
|
'/:id',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
validate(updateEmailTemplateSchema),
|
validate(updateEmailTemplateSchema),
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -133,7 +134,7 @@ router.put(
|
|||||||
*/
|
*/
|
||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
// Fetch template before deleting to get the key
|
// Fetch template before deleting to get the key
|
||||||
@ -167,7 +168,7 @@ router.delete(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/:id/versions',
|
'/:id/versions',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const versions = await emailTemplatesService.getVersions(req.params.id as string);
|
const versions = await emailTemplatesService.getVersions(req.params.id as string);
|
||||||
@ -185,7 +186,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/:id/versions/:versionNumber',
|
'/:id/versions/:versionNumber',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const version = await emailTemplatesService.getVersion(
|
const version = await emailTemplatesService.getVersion(
|
||||||
@ -210,7 +211,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/rollback',
|
'/:id/rollback',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
validate(rollbackToVersionSchema),
|
validate(rollbackToVersionSchema),
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -237,7 +238,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/validate',
|
'/validate',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
validate(validateTemplateSchema),
|
validate(validateTemplateSchema),
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
@ -257,7 +258,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/:id/test',
|
'/:id/test',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
rateLimit({
|
rateLimit({
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
max: 10,
|
max: 10,
|
||||||
@ -291,7 +292,7 @@ router.post(
|
|||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/:id/test-logs',
|
'/:id/test-logs',
|
||||||
requireAdminRole,
|
requireBroadcastRole,
|
||||||
async (req: Request, res: Response): Promise<void> => {
|
async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const limit = req.query.limit ? parseInt(req.query.limit as string, 10) : 10;
|
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 { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { galleryAdsService } from './gallery-ads.service';
|
import { galleryAdsService } from './gallery-ads.service';
|
||||||
import { createAdSchema, updateAdSchema, listAdsSchema, reorderAdsSchema, adAnalyticsQuerySchema } from './gallery-ads.schemas';
|
import { createAdSchema, updateAdSchema, listAdsSchema, reorderAdsSchema, adAnalyticsQuerySchema } from './gallery-ads.schemas';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { PAYMENTS_ROLES } from '../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(UserRole.SUPER_ADMIN));
|
router.use(requireRole(...PAYMENTS_ROLES));
|
||||||
|
|
||||||
// GET /api/gallery-ads/admin — list all ads (paginated)
|
// GET /api/gallery-ads/admin — list all ads (paginated)
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { campaignEmailsService } from './campaign-emails.service';
|
import { campaignEmailsService } from './campaign-emails.service';
|
||||||
import {
|
import {
|
||||||
sendCampaignEmailSchema,
|
sendCampaignEmailSchema,
|
||||||
@ -10,8 +9,7 @@ import { validate } from '../../../middleware/validate';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { emailRateLimit } from '../../../middleware/rate-limit';
|
import { emailRateLimit } from '../../../middleware/rate-limit';
|
||||||
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// --- Public Routes (no auth) ---
|
// --- Public Routes (no auth) ---
|
||||||
const publicRouter = Router();
|
const publicRouter = Router();
|
||||||
@ -53,7 +51,7 @@ publicRouter.post(
|
|||||||
// --- Admin Routes (auth required) ---
|
// --- Admin Routes (auth required) ---
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
adminRouter.use(authenticate);
|
adminRouter.use(authenticate);
|
||||||
adminRouter.use(requireRole(...ADMIN_ROLES));
|
adminRouter.use(requireRole(...INFLUENCE_ROLES));
|
||||||
|
|
||||||
// GET /api/campaigns/:id/emails
|
// GET /api/campaigns/:id/emails
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { campaignsService } from './campaigns.service';
|
import { campaignsService } from './campaigns.service';
|
||||||
import { listModerationQueueSchema, moderateCampaignSchema } from './campaigns.schemas';
|
import { listModerationQueueSchema, moderateCampaignSchema } from './campaigns.schemas';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...ADMIN_ROLES));
|
router.use(requireRole(...INFLUENCE_ROLES));
|
||||||
|
|
||||||
// GET /api/campaigns/moderation/queue — list moderation queue
|
// GET /api/campaigns/moderation/queue — list moderation queue
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { campaignsService } from './campaigns.service';
|
import { campaignsService } from './campaigns.service';
|
||||||
import { createCampaignSchema, updateCampaignSchema, listCampaignsSchema } from './campaigns.schemas';
|
import { createCampaignSchema, updateCampaignSchema, listCampaignsSchema } from './campaigns.schemas';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// All campaign admin routes require authentication + admin role
|
// All campaign admin routes require authentication + admin role
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...ADMIN_ROLES));
|
router.use(requireRole(...INFLUENCE_ROLES));
|
||||||
|
|
||||||
// GET /api/campaigns — list campaigns with pagination/filters
|
// GET /api/campaigns — list campaigns with pagination/filters
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
@ -10,12 +9,11 @@ import {
|
|||||||
geoQuerySchema,
|
geoQuerySchema,
|
||||||
repQuerySchema,
|
repQuerySchema,
|
||||||
} from './effectiveness.schemas';
|
} from './effectiveness.schemas';
|
||||||
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...ADMIN_ROLES));
|
router.use(requireRole(...INFLUENCE_ROLES));
|
||||||
|
|
||||||
// GET /api/influence/effectiveness/overview
|
// GET /api/influence/effectiveness/overview
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { emailQueueService } from '../../../services/email-queue.service';
|
import { emailQueueService } from '../../../services/email-queue.service';
|
||||||
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...ADMIN_ROLES));
|
router.use(requireRole(...INFLUENCE_ROLES));
|
||||||
|
|
||||||
// GET /api/email-queue/stats
|
// GET /api/email-queue/stats
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { representativesService } from './representatives.service';
|
import { representativesService } from './representatives.service';
|
||||||
import { listRepresentativesSchema } from './representatives.schemas';
|
import { listRepresentativesSchema } from './representatives.schemas';
|
||||||
import { postalCodeParamSchema, postalCodeQuerySchema } from '../postal-codes/postal-codes.schemas';
|
import { postalCodeParamSchema, postalCodeQuerySchema } from '../postal-codes/postal-codes.schemas';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -50,7 +48,7 @@ router.get(
|
|||||||
// =============================================
|
// =============================================
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...ADMIN_ROLES));
|
router.use(requireRole(...INFLUENCE_ROLES));
|
||||||
|
|
||||||
// GET /api/representatives/cache-stats — cache statistics
|
// GET /api/representatives/cache-stats — cache statistics
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { responsesService } from './responses.service';
|
import { responsesService } from './responses.service';
|
||||||
import {
|
import {
|
||||||
submitResponseSchema,
|
submitResponseSchema,
|
||||||
@ -12,8 +11,7 @@ import { authenticate } from '../../../middleware/auth.middleware';
|
|||||||
import { optionalAuth } from '../../../middleware/auth.middleware';
|
import { optionalAuth } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { responseRateLimit } from '../../../middleware/rate-limit';
|
import { responseRateLimit } from '../../../middleware/rate-limit';
|
||||||
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// --- Campaign-scoped public routes (mount at /api/campaigns) ---
|
// --- Campaign-scoped public routes (mount at /api/campaigns) ---
|
||||||
const campaignPublicRouter = Router();
|
const campaignPublicRouter = Router();
|
||||||
@ -144,7 +142,7 @@ responsesPublicRouter.get(
|
|||||||
// --- Admin routes (mount at /api/responses) ---
|
// --- Admin routes (mount at /api/responses) ---
|
||||||
const responsesAdminRouter = Router();
|
const responsesAdminRouter = Router();
|
||||||
responsesAdminRouter.use(authenticate);
|
responsesAdminRouter.use(authenticate);
|
||||||
responsesAdminRouter.use(requireRole(...ADMIN_ROLES));
|
responsesAdminRouter.use(requireRole(...INFLUENCE_ROLES));
|
||||||
|
|
||||||
// GET /api/responses
|
// GET /api/responses
|
||||||
responsesAdminRouter.get(
|
responsesAdminRouter.get(
|
||||||
|
|||||||
@ -1,15 +1,15 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { listmonkClient } from '../../services/listmonk.client';
|
import { listmonkClient } from '../../services/listmonk.client';
|
||||||
import { listmonkSyncService } from '../../services/listmonk-sync.service';
|
import { listmonkSyncService } from '../../services/listmonk-sync.service';
|
||||||
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
|
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
|
import { BROADCAST_ROLES } from '../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(UserRole.SUPER_ADMIN));
|
router.use(requireRole(...BROADCAST_ROLES));
|
||||||
|
|
||||||
// GET /api/listmonk — sync status
|
// GET /api/listmonk — sync status
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -8,10 +8,11 @@ import {
|
|||||||
exportContactsToCampaign,
|
exportContactsToCampaign,
|
||||||
getCutCampaignAnalytics,
|
getCutCampaignAnalytics,
|
||||||
} from './canvass-export.service';
|
} from './canvass-export.service';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
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
|
// POST /api/map/canvass/export-contacts/preview — preview matching contacts
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@ -20,8 +20,7 @@ import { validate } from '../../../middleware/validate';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit';
|
import { canvassVisitRateLimit, canvassBulkVisitRateLimit, canvassGeocodeRateLimit } from '../../../middleware/rate-limit';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// ─── Volunteer Router ────────────────────────────────────────────────
|
// ─── Volunteer Router ────────────────────────────────────────────────
|
||||||
const volunteerRouter = Router();
|
const volunteerRouter = Router();
|
||||||
@ -282,7 +281,7 @@ volunteerRouter.post(
|
|||||||
// ─── Admin Router ────────────────────────────────────────────────────
|
// ─── Admin Router ────────────────────────────────────────────────────
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
adminRouter.use(authenticate);
|
adminRouter.use(authenticate);
|
||||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
adminRouter.use(requireRole(...MAP_ROLES));
|
||||||
|
|
||||||
// GET /api/map/canvass/stats
|
// GET /api/map/canvass/stats
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
|
|||||||
@ -1,11 +1,11 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { cutsService } from './cuts.service';
|
import { cutsService } from './cuts.service';
|
||||||
import { createCutSchema, updateCutSchema, listCutsSchema } from './cuts.schemas';
|
import { createCutSchema, updateCutSchema, listCutsSchema } from './cuts.schemas';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const geojsonUpload = multer({
|
const geojsonUpload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage: multer.memoryStorage(),
|
||||||
@ -19,12 +19,10 @@ const geojsonUpload = multer({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// --- Admin Router ---
|
// --- Admin Router ---
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
adminRouter.use(authenticate);
|
adminRouter.use(authenticate);
|
||||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
adminRouter.use(requireRole(...MAP_ROLES));
|
||||||
|
|
||||||
// GET /api/map/cuts — list paginated
|
// GET /api/map/cuts — list paginated
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { geocodingService } from './geocoding.service';
|
import { geocodingService } from './geocoding.service';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const searchSchema = z.object({
|
const searchSchema = z.object({
|
||||||
q: z.string().min(2, 'Query must be at least 2 characters'),
|
q: z.string().min(2, 'Query must be at least 2 characters'),
|
||||||
@ -15,7 +13,7 @@ const searchSchema = z.object({
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...MAP_ADMIN_ROLES));
|
router.use(requireRole(...MAP_ROLES));
|
||||||
|
|
||||||
// GET /api/map/geocoding/search?q=Ottawa&limit=5
|
// GET /api/map/geocoding/search?q=Ottawa&limit=5
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.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 { areaImportService, type AreaImportProgress } from './area-import.service';
|
||||||
import { redis } from '../../../config/redis';
|
import { redis } from '../../../config/redis';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const areaImportRouter = Router();
|
const areaImportRouter = Router();
|
||||||
areaImportRouter.use(authenticate);
|
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
|
// POST /api/map/area-import/preview — get bounds, estimates, and existing count
|
||||||
areaImportRouter.post(
|
areaImportRouter.post(
|
||||||
|
|||||||
@ -1,16 +1,14 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { geocodeQueueService } from '../../../services/geocode-queue.service';
|
import { geocodeQueueService } from '../../../services/geocode-queue.service';
|
||||||
import { bulkGeocodeSchema } from './bulk-geocode.schemas';
|
import { bulkGeocodeSchema } from './bulk-geocode.schemas';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...MAP_ADMIN_ROLES));
|
router.use(requireRole(...MAP_ROLES));
|
||||||
|
|
||||||
// POST /api/map/locations/bulk-geocode — start bulk geocoding job
|
// POST /api/map/locations/bulk-geocode — start bulk geocoding job
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import multer from 'multer';
|
import multer from 'multer';
|
||||||
import { locationsService } from './locations.service';
|
import { locationsService } from './locations.service';
|
||||||
import {
|
import {
|
||||||
@ -17,8 +16,7 @@ import { prisma } from '../../../config/database';
|
|||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// Multer config for CSV upload (memory storage, 10MB limit)
|
// Multer config for CSV upload (memory storage, 10MB limit)
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
@ -49,7 +47,7 @@ const bulkUpload = multer({
|
|||||||
// --- Admin Router ---
|
// --- Admin Router ---
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
adminRouter.use(authenticate);
|
adminRouter.use(authenticate);
|
||||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
adminRouter.use(requireRole(...MAP_ROLES));
|
||||||
|
|
||||||
// GET /api/map/locations — list with pagination + filters
|
// GET /api/map/locations — list with pagination + filters
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import { narImportService, writeProgress } from './nar-import.service';
|
import { narImportService, writeProgress } from './nar-import.service';
|
||||||
@ -8,8 +7,7 @@ import { logger } from '../../../utils/logger';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const serverImportSchema = z.object({
|
const serverImportSchema = z.object({
|
||||||
provinceCode: z.string().min(1).max(2),
|
provinceCode: z.string().min(1).max(2),
|
||||||
@ -24,7 +22,7 @@ const serverImportSchema = z.object({
|
|||||||
|
|
||||||
const narImportRouter = Router();
|
const narImportRouter = Router();
|
||||||
narImportRouter.use(authenticate);
|
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
|
// GET /api/map/nar-import/datasets — list available NAR datasets by province
|
||||||
narImportRouter.get(
|
narImportRouter.get(
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { mapSettingsService } from './settings.service';
|
import { mapSettingsService } from './settings.service';
|
||||||
import { updateMapSettingsSchema } from './settings.schemas';
|
import { updateMapSettingsSchema } from './settings.schemas';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -27,7 +25,7 @@ router.get(
|
|||||||
router.put(
|
router.put(
|
||||||
'/',
|
'/',
|
||||||
authenticate,
|
authenticate,
|
||||||
requireRole(...MAP_ADMIN_ROLES),
|
requireRole(...MAP_ROLES),
|
||||||
validate(updateMapSettingsSchema),
|
validate(updateMapSettingsSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -2,14 +2,14 @@ import { Router } from 'express';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { ShiftSeriesService } from './shift-series.service';
|
import { ShiftSeriesService } from './shift-series.service';
|
||||||
import { createShiftSeriesSchema, updateShiftSeriesSchema } from './shift-series.schemas';
|
import { createShiftSeriesSchema, updateShiftSeriesSchema } from './shift-series.schemas';
|
||||||
|
import { SCHEDULING_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// All routes require admin role
|
// All routes require admin role
|
||||||
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN));
|
router.use(authenticate, requireRole(...SCHEDULING_ROLES));
|
||||||
|
|
||||||
// Create series
|
// Create series
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { shiftsService } from './shifts.service';
|
import { shiftsService } from './shifts.service';
|
||||||
import {
|
import {
|
||||||
createShiftSchema,
|
createShiftSchema,
|
||||||
@ -14,13 +13,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
|
|||||||
import { shiftSignupRateLimit } from '../../../middleware/rate-limit';
|
import { shiftSignupRateLimit } from '../../../middleware/rate-limit';
|
||||||
import { prisma } from '../../../config/database';
|
import { prisma } from '../../../config/database';
|
||||||
import { redis } from '../../../config/redis';
|
import { redis } from '../../../config/redis';
|
||||||
|
import { SCHEDULING_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// --- Admin Router ---
|
// --- Admin Router ---
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
adminRouter.use(authenticate);
|
adminRouter.use(authenticate);
|
||||||
adminRouter.use(requireRole(...MAP_ADMIN_ROLES));
|
adminRouter.use(requireRole(...SCHEDULING_ROLES));
|
||||||
|
|
||||||
// GET /api/map/shifts — list paginated
|
// GET /api/map/shifts — list paginated
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { trackingService } from './tracking.service';
|
import { trackingService } from './tracking.service';
|
||||||
import {
|
import {
|
||||||
startTrackingSchema,
|
startTrackingSchema,
|
||||||
@ -13,8 +12,7 @@ import { validate } from '../../../middleware/validate';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { gpsTrackingRateLimit } from '../../../middleware/rate-limit';
|
import { gpsTrackingRateLimit } from '../../../middleware/rate-limit';
|
||||||
|
import { MAP_ROLES } from '../../../utils/roles';
|
||||||
const MAP_ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// ─── Volunteer Router ────────────────────────────────────────────────
|
// ─── Volunteer Router ────────────────────────────────────────────────
|
||||||
const volunteerRouter = Router();
|
const volunteerRouter = Router();
|
||||||
@ -135,7 +133,7 @@ volunteerRouter.get(
|
|||||||
// ─── Admin Router ────────────────────────────────────────────────────
|
// ─── Admin Router ────────────────────────────────────────────────────
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
adminRouter.use(authenticate);
|
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
|
// GET /api/map/tracking/live — active volunteers with positions + recent trails
|
||||||
adminRouter.get(
|
adminRouter.get(
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import jwt from 'jsonwebtoken';
|
|||||||
import { UserRole, UserStatus } from '@prisma/client';
|
import { UserRole, UserStatus } from '@prisma/client';
|
||||||
import { prisma } from '../../../config/database';
|
import { prisma } from '../../../config/database';
|
||||||
import { env } from '../../../config/env';
|
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
|
// Extend FastifyRequest to include user
|
||||||
declare module 'fastify' {
|
declare module 'fastify' {
|
||||||
@ -123,7 +123,7 @@ export async function requireAdminRole(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check admin role using multi-role utility
|
// 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({
|
return reply.status(403).send({
|
||||||
error: 'Admin access required',
|
error: 'Admin access required',
|
||||||
code: 'ADMIN_REQUIRED'
|
code: 'ADMIN_REQUIRED'
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { prisma } from '../../../config/database';
|
|||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { requireAdminRole } from '../middleware/auth';
|
import { requireAdminRole } from '../middleware/auth';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { hasAnyRole, ADMIN_ROLES } from '../../../utils/roles';
|
import { hasAnyRole, MEDIA_ROLES } from '../../../utils/roles';
|
||||||
import { unlink } from 'fs/promises';
|
import { unlink } from 'fs/promises';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,7 +32,7 @@ async function isAdminRequest(request: FastifyRequest): Promise<boolean> {
|
|||||||
roles?: UserRole[];
|
roles?: UserRole[];
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!hasAnyRole(payload, ADMIN_ROLES)) return false;
|
if (!hasAnyRole(payload, MEDIA_ROLES)) return false;
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: payload.id },
|
where: { id: payload.id },
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { UserRole, UserStatus } from '@prisma/client';
|
|||||||
import { prisma } from '../../../config/database';
|
import { prisma } from '../../../config/database';
|
||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { logger } from '../../../utils/logger';
|
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.
|
* 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)
|
// 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
|
// Verify user is still active in DB
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { meetingPlannerService } from './meeting-planner.service';
|
import { meetingPlannerService } from './meeting-planner.service';
|
||||||
import {
|
import {
|
||||||
createPollSchema,
|
createPollSchema,
|
||||||
@ -13,17 +12,16 @@ import {
|
|||||||
listPollsSchema,
|
listPollsSchema,
|
||||||
} from './meeting-planner.schemas';
|
} from './meeting-planner.schemas';
|
||||||
import { validate } from '../../middleware/validate';
|
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 { requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { pollVoteRateLimit, pollCommentRateLimit } from './meeting-planner.rate-limits';
|
import { pollVoteRateLimit, pollCommentRateLimit } from './meeting-planner.rate-limits';
|
||||||
|
import { EVENTS_ROLES } from '../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
// --- Admin Router ---
|
// --- Admin Router ---
|
||||||
|
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
adminRouter.use(authenticate);
|
adminRouter.use(authenticate);
|
||||||
adminRouter.use(requireRole(...ADMIN_ROLES));
|
adminRouter.use(requireRole(...EVENTS_ROLES));
|
||||||
|
|
||||||
// List polls
|
// List polls
|
||||||
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
adminRouter.get('/', validate(listPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@ -151,7 +149,7 @@ const publicRouter = Router();
|
|||||||
// Public listing of open polls
|
// Public listing of open polls
|
||||||
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
|
publicRouter.get('/public', async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await meetingPlannerService.findAll({
|
const result = await meetingPlannerService.findAllPublic({
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
limit: 50,
|
limit: 50,
|
||||||
page: 1,
|
page: 1,
|
||||||
@ -161,51 +159,28 @@ publicRouter.get('/public', async (req: Request, res: Response, next: NextFuncti
|
|||||||
});
|
});
|
||||||
|
|
||||||
// View poll by slug
|
// 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 {
|
try {
|
||||||
const slug = req.params.slug as string;
|
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);
|
res.json(poll);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Submit votes
|
// 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 {
|
try {
|
||||||
const slug = req.params.slug as string;
|
const slug = req.params.slug as string;
|
||||||
// Try to get userId from optional auth header
|
const result = await meetingPlannerService.submitVotes(slug, req.body, req.user?.id);
|
||||||
let userId: string | undefined;
|
|
||||||
try {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
|
||||||
const jwt = await import('jsonwebtoken');
|
|
||||||
const { env } = await import('../../config/env');
|
|
||||||
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
|
|
||||||
userId = decoded.id;
|
|
||||||
}
|
|
||||||
} catch { /* not authenticated, that's fine */ }
|
|
||||||
|
|
||||||
const result = await meetingPlannerService.submitVotes(slug, req.body, userId);
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add comment
|
// 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 {
|
try {
|
||||||
const slug = req.params.slug as string;
|
const slug = req.params.slug as string;
|
||||||
let userId: string | undefined;
|
const comment = await meetingPlannerService.addComment(slug, req.body, req.user?.id);
|
||||||
try {
|
|
||||||
const authHeader = req.headers.authorization;
|
|
||||||
if (authHeader?.startsWith('Bearer ')) {
|
|
||||||
const jwt = await import('jsonwebtoken');
|
|
||||||
const { env } = await import('../../config/env');
|
|
||||||
const decoded = jwt.default.verify(authHeader.slice(7), env.JWT_ACCESS_SECRET) as any;
|
|
||||||
userId = decoded.id;
|
|
||||||
}
|
|
||||||
} catch { /* not authenticated */ }
|
|
||||||
|
|
||||||
const comment = await meetingPlannerService.addComment(slug, req.body, userId);
|
|
||||||
res.status(201).json(comment);
|
res.status(201).json(comment);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@ -7,6 +7,7 @@ export const createPollSchema = z.object({
|
|||||||
location: z.string().max(500).optional(),
|
location: z.string().max(500).optional(),
|
||||||
timezone: z.string().default('America/Edmonton'),
|
timezone: z.string().default('America/Edmonton'),
|
||||||
allowAnonymous: z.boolean().optional().default(true),
|
allowAnonymous: z.boolean().optional().default(true),
|
||||||
|
isPrivate: z.boolean().optional().default(false),
|
||||||
notifyOnVote: z.boolean().optional().default(true),
|
notifyOnVote: z.boolean().optional().default(true),
|
||||||
votingDeadline: z.string().datetime().optional(),
|
votingDeadline: z.string().datetime().optional(),
|
||||||
options: z.array(z.object({
|
options: z.array(z.object({
|
||||||
@ -22,6 +23,7 @@ export const updatePollSchema = z.object({
|
|||||||
location: z.string().max(500).nullable().optional(),
|
location: z.string().max(500).nullable().optional(),
|
||||||
timezone: z.string().optional(),
|
timezone: z.string().optional(),
|
||||||
allowAnonymous: z.boolean().optional(),
|
allowAnonymous: z.boolean().optional(),
|
||||||
|
isPrivate: z.boolean().optional(),
|
||||||
notifyOnVote: z.boolean().optional(),
|
notifyOnVote: z.boolean().optional(),
|
||||||
votingDeadline: z.string().datetime().nullable().optional(),
|
votingDeadline: z.string().datetime().nullable().optional(),
|
||||||
status: z.nativeEnum(SchedulingPollStatus).optional(),
|
status: z.nativeEnum(SchedulingPollStatus).optional(),
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import crypto from 'crypto';
|
||||||
import { Prisma, PollVoteValue } from '@prisma/client';
|
import { Prisma, PollVoteValue } from '@prisma/client';
|
||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { AppError } from '../../middleware/error-handler';
|
import { AppError } from '../../middleware/error-handler';
|
||||||
@ -22,6 +23,7 @@ const pollInclude = {
|
|||||||
_count: { select: { options: true, votes: true, comments: true } },
|
_count: { select: { options: true, votes: true, comments: true } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// Admin detail include — returns all vote fields (for admin endpoints)
|
||||||
const pollDetailInclude = {
|
const pollDetailInclude = {
|
||||||
options: {
|
options: {
|
||||||
orderBy: { sortOrder: 'asc' as const },
|
orderBy: { sortOrder: 'asc' as const },
|
||||||
@ -34,6 +36,31 @@ const pollDetailInclude = {
|
|||||||
_count: { select: { options: true, votes: true, comments: true } },
|
_count: { select: { options: true, votes: true, comments: true } },
|
||||||
} as const;
|
} 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 }> }>) {
|
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
|
||||||
return options.map((opt) => {
|
return options.map((opt) => {
|
||||||
let yesCount = 0;
|
let yesCount = 0;
|
||||||
@ -56,7 +83,7 @@ function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollV
|
|||||||
|
|
||||||
function groupVotesByVoter(votes: Array<{
|
function groupVotesByVoter(votes: Array<{
|
||||||
voterName: string;
|
voterName: string;
|
||||||
voterToken: string | null;
|
voterToken?: string | null;
|
||||||
userId: string | null;
|
userId: string | null;
|
||||||
optionId: string;
|
optionId: string;
|
||||||
value: PollVoteValue;
|
value: PollVoteValue;
|
||||||
@ -139,6 +166,68 @@ export const meetingPlannerService = {
|
|||||||
return { ...poll, options: optionsWithCounts, voters };
|
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) {
|
async create(data: CreatePollInput, userId: string) {
|
||||||
const slug = generateSlug(data.title);
|
const slug = generateSlug(data.title);
|
||||||
|
|
||||||
@ -150,6 +239,7 @@ export const meetingPlannerService = {
|
|||||||
location: data.location,
|
location: data.location,
|
||||||
timezone: data.timezone,
|
timezone: data.timezone,
|
||||||
allowAnonymous: data.allowAnonymous,
|
allowAnonymous: data.allowAnonymous,
|
||||||
|
isPrivate: data.isPrivate,
|
||||||
notifyOnVote: data.notifyOnVote,
|
notifyOnVote: data.notifyOnVote,
|
||||||
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
|
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
|
||||||
createdByUserId: userId,
|
createdByUserId: userId,
|
||||||
@ -178,6 +268,7 @@ export const meetingPlannerService = {
|
|||||||
if (data.location !== undefined) updateData.location = data.location;
|
if (data.location !== undefined) updateData.location = data.location;
|
||||||
if (data.timezone !== undefined) updateData.timezone = data.timezone;
|
if (data.timezone !== undefined) updateData.timezone = data.timezone;
|
||||||
if (data.allowAnonymous !== undefined) updateData.allowAnonymous = data.allowAnonymous;
|
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.notifyOnVote !== undefined) updateData.notifyOnVote = data.notifyOnVote;
|
||||||
if (data.votingDeadline !== undefined) {
|
if (data.votingDeadline !== undefined) {
|
||||||
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
|
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
|
||||||
@ -263,6 +354,9 @@ export const meetingPlannerService = {
|
|||||||
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
|
if (poll.votingDeadline && new Date() > poll.votingDeadline) {
|
||||||
throw new AppError(400, 'The voting deadline has passed');
|
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) {
|
if (!poll.allowAnonymous && !userId) {
|
||||||
throw new AppError(401, 'This poll requires authentication to vote');
|
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) {
|
async addComment(slug: string, data: SubmitCommentInput, userId?: string) {
|
||||||
const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
|
const poll = await prisma.schedulingPoll.findUnique({ where: { slug } });
|
||||||
if (!poll) throw new AppError(404, 'Poll not found');
|
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({
|
return prisma.schedulingPollComment.create({
|
||||||
data: {
|
data: {
|
||||||
@ -517,12 +617,7 @@ export const meetingPlannerService = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function generateVoterToken(): string {
|
function generateVoterToken(): string {
|
||||||
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
return crypto.randomBytes(18).toString('base64url').slice(0, 24);
|
||||||
let token = '';
|
|
||||||
for (let i = 0; i < 24; i++) {
|
|
||||||
token += chars[Math.floor(Math.random() * chars.length)];
|
|
||||||
}
|
|
||||||
return token;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str: string): string {
|
function escapeHtml(str: string): string {
|
||||||
|
|||||||
@ -1,17 +1,15 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { blocksService } from './blocks.service';
|
import { blocksService } from './blocks.service';
|
||||||
import { createPageBlockSchema, updatePageBlockSchema, listPageBlocksSchema } from './pages.schemas';
|
import { createPageBlockSchema, updatePageBlockSchema, listPageBlocksSchema } from './pages.schemas';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { CONTENT_ROLES } from '../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...ADMIN_ROLES));
|
router.use(requireRole(...CONTENT_ROLES));
|
||||||
|
|
||||||
// GET /api/page-blocks — list all blocks
|
// GET /api/page-blocks — list all blocks
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,18 +1,16 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { pagesService } from './pages.service';
|
import { pagesService } from './pages.service';
|
||||||
import { createLandingPageSchema, updateLandingPageSchema, listLandingPagesSchema } from './pages.schemas';
|
import { createLandingPageSchema, updateLandingPageSchema, listLandingPagesSchema } from './pages.schemas';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
|
import { CONTENT_ROLES } from '../../utils/roles';
|
||||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
router.use(requireRole(...ADMIN_ROLES));
|
router.use(requireRole(...CONTENT_ROLES));
|
||||||
|
|
||||||
// GET /api/pages/view-counts — landing page view counts (last 30d)
|
// GET /api/pages/view-counts — landing page view counts (last 30d)
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { PAYMENTS_ROLES } from '../../utils/roles';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { donationPagesService } from './donation-pages.service';
|
import { donationPagesService } from './donation-pages.service';
|
||||||
import {
|
import {
|
||||||
@ -12,8 +12,8 @@ import {
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// All routes require SUPER_ADMIN
|
// All routes require PAYMENTS_ROLES
|
||||||
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
|
router.use(authenticate, requireRole(...PAYMENTS_ROLES));
|
||||||
|
|
||||||
// GET /api/payments/admin/donation-pages — list with pagination, search, status
|
// GET /api/payments/admin/donation-pages — list with pagination, search, status
|
||||||
router.get(
|
router.get(
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { UserRole } from '@prisma/client';
|
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { PAYMENTS_ROLES } from '../../utils/roles';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { paymentSettingsService } from './payment-settings.service';
|
import { paymentSettingsService } from './payment-settings.service';
|
||||||
import { subscriptionsService } from './subscriptions.service';
|
import { subscriptionsService } from './subscriptions.service';
|
||||||
@ -22,8 +22,8 @@ import {
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// All admin routes require SUPER_ADMIN
|
// All admin routes require PAYMENTS_ROLES
|
||||||
router.use(authenticate, requireRole(UserRole.SUPER_ADMIN));
|
router.use(authenticate, requireRole(...PAYMENTS_ROLES));
|
||||||
|
|
||||||
// =================== Settings ===================
|
// =================== Settings ===================
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
|
|||||||
import { emailService } from '../../services/email.service';
|
import { emailService } from '../../services/email.service';
|
||||||
import { giteaClient } from '../../services/gitea.client';
|
import { giteaClient } from '../../services/gitea.client';
|
||||||
import { gancioSettingsSyncService } from '../../services/gancio-settings-sync.service';
|
import { gancioSettingsSyncService } from '../../services/gancio-settings-sync.service';
|
||||||
|
import { autoUpgradeService } from '../../services/auto-upgrade.service';
|
||||||
import { headerBuilderService } from '../docs/header-builder.service';
|
import { headerBuilderService } from '../docs/header-builder.service';
|
||||||
import { mkdocsConfigService } from '../docs/mkdocs-config.service';
|
import { mkdocsConfigService } from '../docs/mkdocs-config.service';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
@ -35,6 +36,7 @@ router.get(
|
|||||||
async (_req: Request, res: Response, next: NextFunction) => {
|
async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const settings = await siteSettingsService.getEffective();
|
const settings = await siteSettingsService.getEffective();
|
||||||
|
res.set('Cache-Control', 'no-store');
|
||||||
res.json(settings);
|
res.json(settings);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@ -115,6 +117,12 @@ router.put(
|
|||||||
gancioSettingsSyncService.syncChanged(req.body).catch(() => {});
|
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
|
// If navConfig or theme colors changed, trigger MkDocs header rebuild + docs build
|
||||||
const headerTriggerFields = [
|
const headerTriggerFields = [
|
||||||
'navConfig', 'publicHeaderGradient', 'publicColorBgBase', 'publicColorBgContainer',
|
'navConfig', 'publicHeaderGradient', 'publicColorBgBase', 'publicColorBgContainer',
|
||||||
|
|||||||
@ -59,6 +59,7 @@ export const updateSiteSettingsSchema = z.object({
|
|||||||
enableMeetingPlanner: z.boolean().optional(),
|
enableMeetingPlanner: z.boolean().optional(),
|
||||||
enableTicketedEvents: z.boolean().optional(),
|
enableTicketedEvents: z.boolean().optional(),
|
||||||
enableSocialCalendar: z.boolean().optional(),
|
enableSocialCalendar: z.boolean().optional(),
|
||||||
|
enableDocsCollaboration: z.boolean().optional(),
|
||||||
requireEventApproval: z.boolean().optional(),
|
requireEventApproval: z.boolean().optional(),
|
||||||
autoSyncPeopleToMap: z.boolean().optional(),
|
autoSyncPeopleToMap: z.boolean().optional(),
|
||||||
|
|
||||||
@ -86,6 +87,15 @@ export const updateSiteSettingsSchema = z.object({
|
|||||||
provisionListmonk: z.boolean().optional(),
|
provisionListmonk: z.boolean().optional(),
|
||||||
provisionListmonkTiming: z.enum(['lazy', 'eager']).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)
|
// Navigation configuration (supports one level of nesting via groups)
|
||||||
navConfig: z.object({
|
navConfig: z.object({
|
||||||
items: z.array(z.object({
|
items: z.array(z.object({
|
||||||
|
|||||||
@ -5,11 +5,12 @@ import { validate } from '../../../middleware/validate';
|
|||||||
import { smsCampaignsService } from './sms-campaigns.service';
|
import { smsCampaignsService } from './sms-campaigns.service';
|
||||||
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
|
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
|
||||||
import { smsQueueService } from '../../../services/sms-queue.service';
|
import { smsQueueService } from '../../../services/sms-queue.service';
|
||||||
|
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
|
// All routes require authentication + broadcast admin role
|
||||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||||
|
|
||||||
// GET /api/sms/campaigns — list all campaigns
|
// GET /api/sms/campaigns — list all campaigns
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
|
|||||||
@ -4,11 +4,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
|
|||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { smsContactsService } from './sms-contacts.service';
|
import { smsContactsService } from './sms-contacts.service';
|
||||||
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
|
import { createContactListSchema, updateContactListSchema, createContactEntrySchema, bulkAddEntriesSchema } from './sms-contacts.schemas';
|
||||||
|
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
|
// All routes require authentication + broadcast admin role
|
||||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||||
|
|
||||||
// --- Contact Lists ---
|
// --- Contact Lists ---
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import { Router } from 'express';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { smsConversationsService } from './sms-conversations.service';
|
import { smsConversationsService } from './sms-conversations.service';
|
||||||
|
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||||
|
|
||||||
// GET /api/sms/conversations — list conversations
|
// GET /api/sms/conversations — list conversations
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
|
|||||||
@ -3,10 +3,11 @@ import { authenticate } from '../../../middleware/auth.middleware';
|
|||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { smsDeviceService } from './sms-device.service';
|
import { smsDeviceService } from './sms-device.service';
|
||||||
import { termuxClient } from '../../../services/termux.client';
|
import { termuxClient } from '../../../services/termux.client';
|
||||||
|
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||||
|
|
||||||
// GET /api/sms/device — latest device status
|
// GET /api/sms/device — latest device status
|
||||||
router.get('/', async (_req, res, next) => {
|
router.get('/', async (_req, res, next) => {
|
||||||
|
|||||||
@ -2,10 +2,11 @@ import { Router } from 'express';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { smsMessagesService } from './sms-messages.service';
|
import { smsMessagesService } from './sms-messages.service';
|
||||||
|
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||||
|
|
||||||
// GET /api/sms/messages — list all messages
|
// GET /api/sms/messages — list all messages
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
|
|||||||
@ -4,11 +4,12 @@ import { requireRole } from '../../../middleware/rbac.middleware';
|
|||||||
import { validate } from '../../../middleware/validate';
|
import { validate } from '../../../middleware/validate';
|
||||||
import { smsTemplatesService } from './sms-templates.service';
|
import { smsTemplatesService } from './sms-templates.service';
|
||||||
import { createSmsTemplateSchema, updateSmsTemplateSchema } from './sms-templates.schemas';
|
import { createSmsTemplateSchema, updateSmsTemplateSchema } from './sms-templates.schemas';
|
||||||
|
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
// All routes require authentication + SUPER_ADMIN or INFLUENCE_ADMIN
|
// All routes require authentication + broadcast admin role
|
||||||
router.use(authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'));
|
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||||
|
|
||||||
// GET /api/sms/templates — list with search/filter/pagination
|
// GET /api/sms/templates — list with search/filter/pagination
|
||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||||
import { challengeService } from './challenge.service';
|
import { challengeService } from './challenge.service';
|
||||||
import {
|
import {
|
||||||
createChallengeSchema,
|
createChallengeSchema,
|
||||||
@ -98,7 +99,7 @@ router.get('/:id/teams/:teamId', async (req: Request, res: Response) => {
|
|||||||
// ── Admin ────────────────────────────────────────────────────────────
|
// ── Admin ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const adminRouter = Router();
|
const adminRouter = Router();
|
||||||
adminRouter.use(requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'));
|
adminRouter.use(requireRole(...SOCIAL_ROLES));
|
||||||
|
|
||||||
/** POST /admin — create challenge */
|
/** POST /admin — create challenge */
|
||||||
adminRouter.post('/', async (req: Request, res: Response) => {
|
adminRouter.post('/', async (req: Request, res: Response) => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { INFLUENCE_ROLES } from '../../utils/roles';
|
||||||
import { impactStoriesService } from './impact-stories.service';
|
import { impactStoriesService } from './impact-stories.service';
|
||||||
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
|
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
|
||||||
|
|
||||||
@ -7,7 +8,7 @@ const router = Router();
|
|||||||
|
|
||||||
// --- Admin routes (require admin role) ---
|
// --- 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 {
|
try {
|
||||||
const data = createStorySchema.parse(req.body);
|
const data = createStorySchema.parse(req.body);
|
||||||
const story = await impactStoriesService.create(data, req.user!.id);
|
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 {
|
try {
|
||||||
const data = updateStorySchema.parse(req.body);
|
const data = updateStorySchema.parse(req.body);
|
||||||
const story = await impactStoriesService.update(req.params.id as string, data);
|
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 {
|
try {
|
||||||
const result = await impactStoriesService.delete(req.params.id as string);
|
const result = await impactStoriesService.delete(req.params.id as string);
|
||||||
res.json(result);
|
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 {
|
try {
|
||||||
const story = await impactStoriesService.publish(req.params.id as string);
|
const story = await impactStoriesService.publish(req.params.id as string);
|
||||||
// Fire-and-forget: notify participants
|
// 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 {
|
try {
|
||||||
const story = await impactStoriesService.archive(req.params.id as string);
|
const story = await impactStoriesService.archive(req.params.id as string);
|
||||||
res.json(story);
|
res.json(story);
|
||||||
@ -64,7 +65,7 @@ router.get('/', async (req, res, next) => {
|
|||||||
// Admin users can filter by status; regular users see published only
|
// Admin users can filter by status; regular users see published only
|
||||||
const userRoles = req.user!.roles || [req.user!.role];
|
const userRoles = req.user!.roles || [req.user!.role];
|
||||||
const isAdmin = userRoles.some((r: string) =>
|
const isAdmin = userRoles.some((r: string) =>
|
||||||
['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'].includes(r),
|
(INFLUENCE_ROLES as string[]).includes(r),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (isAdmin && (campaignId || status)) {
|
if (isAdmin && (campaignId || status)) {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||||
import { referralService } from './referral.service';
|
import { referralService } from './referral.service';
|
||||||
import { createInviteCodeSchema, validateCodeSchema, paginationSchema } from './referral.schemas';
|
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) */
|
/** 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 {
|
try {
|
||||||
const { page, limit } = paginationSchema.parse(req.query);
|
const { page, limit } = paginationSchema.parse(req.query);
|
||||||
const result = await referralService.listAllReferrals(page, limit);
|
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) */
|
/** 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 {
|
try {
|
||||||
const limit = parseInt((req.query.limit as string) || '10', 10);
|
const limit = parseInt((req.query.limit as string) || '10', 10);
|
||||||
const leaderboard = await referralService.getReferralLeaderboard(Math.min(limit, 50));
|
const leaderboard = await referralService.getReferralLeaderboard(Math.min(limit, 50));
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||||
import { friendshipRouter } from './friendship.routes';
|
import { friendshipRouter } from './friendship.routes';
|
||||||
import { blockRouter } from './block.routes';
|
import { blockRouter } from './block.routes';
|
||||||
import { privacyRouter } from './privacy.routes';
|
import { privacyRouter } from './privacy.routes';
|
||||||
@ -35,7 +36,7 @@ router.use((req, _res, next) => {
|
|||||||
router.use(authenticate);
|
router.use(authenticate);
|
||||||
|
|
||||||
// Admin sub-router (requires admin role)
|
// 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
|
// Sub-routers
|
||||||
router.use('/friends', friendshipRouter);
|
router.use('/friends', friendshipRouter);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import type { Request, Response, NextFunction } from 'express';
|
import type { Request, Response, NextFunction } from 'express';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
|
import { SOCIAL_ROLES } from '../../utils/roles';
|
||||||
import { spotlightService } from './spotlight.service';
|
import { spotlightService } from './spotlight.service';
|
||||||
import {
|
import {
|
||||||
nominateSchema,
|
nominateSchema,
|
||||||
@ -88,7 +89,7 @@ router.post('/opt-out', async (req: Request, res: Response, next: NextFunction)
|
|||||||
/** GET /api/social/spotlight/admin — list all spotlights */
|
/** GET /api/social/spotlight/admin — list all spotlights */
|
||||||
router.get(
|
router.get(
|
||||||
'/admin',
|
'/admin',
|
||||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
requireRole(...SOCIAL_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { page, limit, status } = listSpotlightsSchema.parse(req.query);
|
const { page, limit, status } = listSpotlightsSchema.parse(req.query);
|
||||||
@ -103,7 +104,7 @@ router.get(
|
|||||||
/** POST /api/social/spotlight/admin/nominate — nominate a volunteer */
|
/** POST /api/social/spotlight/admin/nominate — nominate a volunteer */
|
||||||
router.post(
|
router.post(
|
||||||
'/admin/nominate',
|
'/admin/nominate',
|
||||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
requireRole(...SOCIAL_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const data = nominateSchema.parse(req.body);
|
const data = nominateSchema.parse(req.body);
|
||||||
@ -118,7 +119,7 @@ router.post(
|
|||||||
/** PUT /api/social/spotlight/admin/:id — update headline/story */
|
/** PUT /api/social/spotlight/admin/:id — update headline/story */
|
||||||
router.put(
|
router.put(
|
||||||
'/admin/:id',
|
'/admin/:id',
|
||||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
requireRole(...SOCIAL_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const data = updateSpotlightSchema.parse(req.body);
|
const data = updateSpotlightSchema.parse(req.body);
|
||||||
@ -133,7 +134,7 @@ router.put(
|
|||||||
/** POST /api/social/spotlight/admin/:id/approve — approve a nomination */
|
/** POST /api/social/spotlight/admin/:id/approve — approve a nomination */
|
||||||
router.post(
|
router.post(
|
||||||
'/admin/:id/approve',
|
'/admin/:id/approve',
|
||||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
requireRole(...SOCIAL_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const spotlight = await spotlightService.approve(req.params.id as string, req.user!.id);
|
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 */
|
/** POST /api/social/spotlight/admin/:id/feature — feature for a month */
|
||||||
router.post(
|
router.post(
|
||||||
'/admin/:id/feature',
|
'/admin/:id/feature',
|
||||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
requireRole(...SOCIAL_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const { month } = featureSchema.parse(req.body);
|
const { month } = featureSchema.parse(req.body);
|
||||||
@ -162,7 +163,7 @@ router.post(
|
|||||||
/** POST /api/social/spotlight/admin/:id/archive — archive a spotlight */
|
/** POST /api/social/spotlight/admin/:id/archive — archive a spotlight */
|
||||||
router.post(
|
router.post(
|
||||||
'/admin/:id/archive',
|
'/admin/:id/archive',
|
||||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
requireRole(...SOCIAL_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const spotlight = await spotlightService.archive(req.params.id as string);
|
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 */
|
/** DELETE /api/social/spotlight/admin/:id — delete a spotlight */
|
||||||
router.delete(
|
router.delete(
|
||||||
'/admin/:id',
|
'/admin/:id',
|
||||||
requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'),
|
requireRole(...SOCIAL_ROLES),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const result = await spotlightService.delete(req.params.id as string);
|
const result = await spotlightService.delete(req.params.id as string);
|
||||||
|
|||||||
@ -13,16 +13,16 @@ import {
|
|||||||
} from './ticketed-events.schemas';
|
} from './ticketed-events.schemas';
|
||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { UserRole } from '@prisma/client';
|
import { UserRole } from '@prisma/client';
|
||||||
|
import { EVENTS_ROLES } from '../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN'];
|
|
||||||
|
|
||||||
/** Middleware: require admin role OR canCreateTicketedEvents permission */
|
/** Middleware: require admin role OR canCreateTicketedEvents permission */
|
||||||
async function requireEventPermission(req: Request, _res: Response, next: NextFunction) {
|
async function requireEventPermission(req: Request, _res: Response, next: NextFunction) {
|
||||||
if (!req.user) return next(new Error('Auth required'));
|
if (!req.user) return next(new Error('Auth required'));
|
||||||
|
|
||||||
const userRoles = req.user.roles || [req.user.role];
|
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();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -51,7 +51,7 @@ router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
|||||||
const search = req.query.search as string | undefined;
|
const search = req.query.search as string | undefined;
|
||||||
|
|
||||||
const userRoles = req.user!.roles || [req.user!.role];
|
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({
|
const result = await ticketedEventsService.list({
|
||||||
page,
|
page,
|
||||||
@ -110,7 +110,7 @@ router.post('/:id/publish', async (req: Request, res: Response, next: NextFuncti
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/approve (admin only)
|
// 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 {
|
try {
|
||||||
const event = await ticketedEventsService.approve(req.params.id as string);
|
const event = await ticketedEventsService.approve(req.params.id as string);
|
||||||
res.json(event);
|
res.json(event);
|
||||||
@ -118,7 +118,7 @@ router.post('/:id/approve', requireRole(...ADMIN_ROLES), async (req: Request, re
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/reject (admin only)
|
// 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 {
|
try {
|
||||||
const event = await ticketedEventsService.reject(req.params.id as string);
|
const event = await ticketedEventsService.reject(req.params.id as string);
|
||||||
res.json(event);
|
res.json(event);
|
||||||
@ -134,7 +134,7 @@ router.post('/:id/cancel', async (req: Request, res: Response, next: NextFunctio
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/complete (admin only)
|
// 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 {
|
try {
|
||||||
const event = await ticketedEventsService.complete(req.params.id as string);
|
const event = await ticketedEventsService.complete(req.params.id as string);
|
||||||
res.json(event);
|
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
|
* POST /api/upgrade/clear-result
|
||||||
* Removes the last upgrade result file.
|
* 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 PROGRESS_FILE = path.join(UPGRADE_DIR, 'progress.json');
|
||||||
const RESULT_FILE = path.join(UPGRADE_DIR, 'result.json');
|
const RESULT_FILE = path.join(UPGRADE_DIR, 'result.json');
|
||||||
const TRIGGER_FILE = path.join(UPGRADE_DIR, 'trigger.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
|
// Stale threshold: if progress hasn't been updated in this many ms, assume crashed
|
||||||
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
const STALE_THRESHOLD_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
const MAX_HISTORY_ENTRIES = 50;
|
||||||
|
|
||||||
interface UpgradeStatus {
|
export interface UpgradeStatus {
|
||||||
branch: string;
|
branch: string;
|
||||||
currentCommit: string;
|
currentCommit: string;
|
||||||
currentCommitFull: string;
|
currentCommitFull: string;
|
||||||
@ -37,7 +40,7 @@ interface UpgradeStatus {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpgradeProgress {
|
export interface UpgradeProgress {
|
||||||
phase: number;
|
phase: number;
|
||||||
phaseName: string;
|
phaseName: string;
|
||||||
percentage: number;
|
percentage: number;
|
||||||
@ -45,7 +48,7 @@ interface UpgradeProgress {
|
|||||||
lastUpdate: string;
|
lastUpdate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpgradeResult {
|
export interface UpgradeResult {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
previousCommit: string;
|
previousCommit: string;
|
||||||
@ -54,6 +57,7 @@ interface UpgradeResult {
|
|||||||
durationSeconds: number;
|
durationSeconds: number;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
|
triggeredBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TriggerPayload {
|
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 = {
|
export const upgradeService = {
|
||||||
getStatus,
|
getStatus,
|
||||||
getProgress,
|
getProgress,
|
||||||
@ -179,4 +225,8 @@ export const upgradeService = {
|
|||||||
triggerUpgrade,
|
triggerUpgrade,
|
||||||
clearResult,
|
clearResult,
|
||||||
clearStaleProgress,
|
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