Bunch of updates to scheduling

This commit is contained in:
bunker-admin 2026-03-15 13:50:09 -06:00
parent 12734aca16
commit 28e4bc9475
202 changed files with 4568 additions and 226 deletions

4
.gitignore vendored
View File

@ -47,6 +47,10 @@ docker-compose.override.yml
# Build output
/admin/dist/
# Core dumps
core.*
*/core.*
# MkDocs core binary
/mkdocs/core

View File

@ -0,0 +1,2 @@
[ 288ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:2287
[ 288ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,6 @@
[ 92ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 92ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 496039ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 496039ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 498038ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:0
[ 498038ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,26 @@
[ 121ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:885
[ 121ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 497669ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2201
[ 497669ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 499981ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 499981ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 503949ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 503949ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 506409ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 506409ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 510957ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2302
[ 510957ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 523501ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2304
[ 523501ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 534339ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:891
[ 534339ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 536931ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 536931ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 543415ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2312
[ 543415ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 545948ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2209
[ 545948ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 552080ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:0
[ 552080ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 554689ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/#use-the-platform:2313
[ 554689ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 101ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/:2313
[ 101ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1 @@
[ 287ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4004/favicon.ico:0

View File

@ -0,0 +1,2 @@
[ 118ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2272
[ 118ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 49ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2101
[ 49ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 52ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/getting-started/:2582
[ 52ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 59ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2313
[ 59ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 40ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2226
[ 40ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,6 @@
[ 269ms] ReferenceError: Missing element: expected "[data-md-component=header]" to be present
at j (http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:35799)
at Ce (http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:42721)
at http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:94068
at http://localhost:4000/assets/javascripts/bundle.79ae519e.min.js:14:95391
[ 418ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:4000/favicon.ico:0

View File

@ -0,0 +1,2 @@
[ 339ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:511
[ 339ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 36ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2212
[ 36ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 64ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2315
[ 64ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 189ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:511
[ 189ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,2 @@
[ 150ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:2315
[ 151ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,14 @@
[ 64ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/:893
[ 65ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 926012ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?_=1773266458361:933
[ 926012ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 1794181ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?_=1773267326487:2359
[ 1794181ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 1857070ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?v=1773267389387:2391
[ 1857070ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2018066ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?r=1773267550383:2406
[ 2018066ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2115925ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?final=1773267648297:571
[ 2115925ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0
[ 2810593ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4004' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4004/docs/?ff=1773268342997:961
[ 2810593ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,21 @@
[ 1411ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
[ 11195ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/connectivity:0
[ 11196ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/services/status:0
[ 11197ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/weather:0
[ 11197ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/docs-analytics/summary?days=30:0
[ 11198ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/chat-summary:0
[ 11199ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/rocketchat-stats:0
[ 11199ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/upcoming-shifts:0
[ 11200ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/jitsi/meetings:0
[ 11201ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/influence/effectiveness/overview:0
[ 11201ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/top-videos:0
[ 11203ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/recent-signups:0
[ 11204ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/recent-comments:0
[ 11205ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/listmonk/stats:0
[ 11206ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/listmonk-campaigns:0
[ 11206ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/listmonk:0
[ 11207ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/observability/alerts:0
[ 11208ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/payments/admin/dashboard:0
[ 11209ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/gitea-activity:0
[ 11209ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/dashboard/vaultwarden-adoption:0
[ 11210ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/map/canvass/analytics/cuts:0

View File

@ -0,0 +1,8 @@
[ 788ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/auth/me:0
[ 789ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/settings:0
[ 791ms] [ERROR] Unexpected auth error: AxiosError: Request failed with status code 500
at settle (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1281:12)
at XMLHttpRequest.onloadend (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1638:7)
at Axios.request (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:2255:41)
at async Object.fetchMe (http://localhost:3002/src/stores/auth.store.ts:101:28)
at async hydrate (http://localhost:3002/src/stores/auth.store.ts:118:11) @ http://localhost:3002/src/stores/auth.store.ts:105

View File

@ -0,0 +1,30 @@
[ 960624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1920622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2880624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 3840624ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4800623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5760623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 6720616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 7680622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 8640625ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 9600615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[10560615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[11520625ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[12480623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[13440615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[14400616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[15360616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[16320615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[17280618ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[18240616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[19200622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[20160621ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21120618ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[22080623ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23040622ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24000616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24960616ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[25920615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[26880613ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[27840614ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[28800615ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -0,0 +1,2 @@
[ 92ms] [ERROR] Access to resource at 'http://localhost:4002/api/docs-analytics/track' from origin 'http://localhost:4003' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: No 'Access-Control-Allow-Origin' header is present on the requested resource. @ http://localhost:4003/docs/admin/dashboard/:1574
[ 92ms] [ERROR] Failed to load resource: net::ERR_FAILED @ http://localhost:4002/api/docs-analytics/track:0

View File

@ -0,0 +1,44 @@
[ 1044ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0
[ 1045ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0
[ 957294ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 1915502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 2875494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 3835503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 4795505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 5755494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 6715495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 7675495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 8635495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[ 9595539ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[10555496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[11515504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[12475494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[13435504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[14395501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[15355503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[16315505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[17275496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[18235494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[19195496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[20155502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[21115501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[22075494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23035502ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[23995496ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[24955494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[25915495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[26875500ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[27835504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[28795505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[29755503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[30715505ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[31675500ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[32635503ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[33595504ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[34555501ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[35515495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[36475494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[37435493ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[38395495ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[39355494ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0
[40315488ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/dashboard/summary:0

View File

@ -0,0 +1 @@
[ 915ms] [ERROR] Failed to load resource: the server responded with a status of 404 (Not Found) @ http://localhost:3002/favicon.ico:0

View File

@ -0,0 +1,194 @@
[ 719376ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:3002/api/auth/me:0
[ 949197ms] [ERROR] ReferenceError: MeetingAgendaPage is not defined
at App (http://localhost:3002/src/App.tsx?t=1773363079750:663:127)
at renderWithHooks (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:3520:25)
at updateFunctionComponent (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:5151:19)
at beginWork (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:5762:18)
at performUnitOfWork (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8567:18)
at workLoopSync (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8465:41)
at renderRootSync (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8449:11)
at performWorkOnRoot (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:8124:44)
at performSyncWorkOnRoot (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:9134:7)
at flushSyncWorkAcrossRoots_impl (http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:9042:153) @ http://localhost:3002/node_modules/.vite/deps/chunk-2NI7C5SJ.js?v=c83de56d:4778
[ 953711ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/src/App.tsx?t=1773363084913:0
[ 1676461ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error during WebSocket handshake: net::ERR_CONNECTION_RESET @ http://localhost:3002/@vite/client:1034
[ 1677465ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/@vite/client:1034
[ 1678466ms] [ERROR] WebSocket connection to 'ws://localhost:3002/' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED @ http://localhost:3002/@vite/client:1034
[ 1679810ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ListmonkPage.tsx:0
[ 1679810ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/LandingPagesPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MkDocsSettingsPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/CodeEditorPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NocoDBPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/N8nPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/GiteaPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MailHogPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MiniQRPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ExcalidrawPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/VaultwardenPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/RocketChatPage.tsx:0
[ 1679815ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/GancioPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/JitsiMeetPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/SettingsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NavigationSettingsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/PangolinPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ObservabilityPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsAnalyticsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/DocsCommentsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PaymentsDashboardPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/SubscribersPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/ProductsPage.tsx:0
[ 1679816ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/DonationsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/DonationPagesPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PlansPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/payments/PaymentSettingsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/LibraryPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/AnalyticsDashboardPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/MediaJobsPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/CommentModerationPage.tsx:0
[ 1679818ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/GalleryAdsPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/AdAnalyticsDashboardPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/CampaignModerationPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/CampaignEffectivenessPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/LandingPage.tsx:0
[ 1679819ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PagesIndexPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/EventsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/HomePage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CampaignsListPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CampaignPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/CreateCampaignPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyCampaignsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ResponseWallPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MapPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShiftsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaGalleryPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShortsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MediaViewerPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistBrowsePage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlaylistViewerPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/media/PlaylistManagementPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MyStatsPage.tsx:0
[ 1679820ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MySettingsPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerChatPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PricingPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ShopPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ProductDetailPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PlanDetailPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonatePage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/DonationPagesListPage.tsx:0
[ 1679821ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PaymentSuccessPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyActivityPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerShiftsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyRoutesPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/VolunteerMapPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialProfilePage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/NotificationsPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SocialFeedPage.tsx:0
[ 1679824ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/DiscoverPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/GroupDetailPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/AchievementsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/types/api.ts:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/roles.ts:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/QuickJoinPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/VerifyEmailPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ResetPasswordPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsDashboardPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsContactsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsCampaignsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsConversationsPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsTemplatesPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/sms/SmsSetupPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/PeoplePage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/ContactProfilePage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialDashboardPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialGraphPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SocialModerationPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ReferralAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/SpotlightAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/social/ChallengesAdminPage.tsx:0
[ 1679825ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/influence/ImpactStoriesPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ReferralsPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengesPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/ChallengeDetailPage.tsx:0
[ 1679826ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/WallOfFamePage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/MeetingJoinPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingPlannerPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/MeetingAgendaPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/ActionItemsPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/SchedulingPollPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/PollsListPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/JitsiAuthPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/SchedulingCalendarPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/AdminCalendarViewPage.tsx:0
[ 1679827ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/TicketedEventsPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/EventDetailPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/events/CheckInScannerPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketedEventDetailPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/public/TicketConfirmationPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyTicketsPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/MyCalendarPage.tsx:0
[ 1679828ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarsPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/SharedCalendarViewPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/volunteer/FriendCalendarPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/NotFoundPage.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/command-palette/CommandPalette.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/api.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/VolunteerFooterNav.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/PublicNavBar.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useSSE.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useLocalStorage.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/service-url.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/lib/nav-defaults.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/command-palette.store.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/stores/favorites.store.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/menu-items.ts:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/chat/RocketChatWidget.tsx:0
[ 1679829ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaSidebar.tsx:0
[ 1679830ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/MediaBottomNav.tsx:0
[ 1679830ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/ChatNotificationToast.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBarContext.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/chatbar/ChatBar.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/hooks/useChatNotifications.ts:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/utils/color.ts:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/AuthModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/public/NewsletterSignup.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/pages/CampaignEmailsDrawer.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/ExportContactsModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/QrCodeModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/media/VideoPickerModal.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemGauges.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MiniDonutChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RequestTrafficChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/LatencyBandsChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerPopover.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ContainerMemoryChart.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ActivityFeedCard.tsx:0
[ 1679831ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TodayEventsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/ChatNotifierCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/TopVideosCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentCommentsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DocsAnalyticsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingShiftsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/MyActionItemsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/CampaignEffectivenessCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/RecentSignupsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/NewsletterStatsCard.tsx:0
[ 1679832ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/DonationSummaryCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/SystemAlertsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/GiteaActivityCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/VaultwardenAdoptionCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/dashboard/UpcomingMeetingsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/canvass/CutCampaignAnalyticsCard.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/TestEmailModal.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/VersionHistoryDrawer.tsx:0
[ 1679833ms] [ERROR] Failed to load resource: net::ERR_NETWORK_CHANGED @ http://localhost:3002/src/components/email-templates/EmailTemplateEditor.tsx:0
[ 1685249ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/settings:0
[ 1685251ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/auth/me:0
[ 1685252ms] [ERROR] Unexpected auth error: AxiosError: Request failed with status code 500
at settle (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1281:12)
at XMLHttpRequest.onloadend (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:1638:7)
at Axios.request (http://localhost:3002/node_modules/.vite/deps/axios.js?v=c83de56d:2255:41)
at async Object.fetchMe (http://localhost:3002/src/stores/auth.store.ts:101:28)
at async hydrate (http://localhost:3002/src/stores/auth.store.ts:118:11) @ http://localhost:3002/src/stores/auth.store.ts:105
[ 1685344ms] [ERROR] Failed to load resource: the server responded with a status of 500 (Internal Server Error) @ http://localhost:3002/api/payments/plans:0

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

View File

@ -136,6 +136,8 @@ import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
import WallOfFamePage from '@/pages/public/WallOfFamePage';
import MeetingJoinPage from '@/pages/public/MeetingJoinPage';
import MeetingPlannerPage from '@/pages/MeetingPlannerPage';
import MeetingAgendaPage from '@/pages/MeetingAgendaPage';
import ActionItemsPage from '@/pages/ActionItemsPage';
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage';
@ -803,6 +805,22 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="meetings/agendas"
element={
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
<MeetingAgendaPage />
</ProtectedRoute>
}
/>
<Route
path="meetings/action-items"
element={
<ProtectedRoute requiredRoles={EVENTS_ROLES}>
<ActionItemsPage />
</ProtectedRoute>
}
/>
<Route
path="scheduling/calendar-views/:id"
element={

View File

@ -259,6 +259,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
}
if (settings?.enableMeetingPlanner) {
schedulingChildren.push({ key: '/app/meeting-planner', icon: <ScheduleOutlined />, label: 'Meeting Planner' });
schedulingChildren.push({ key: '/app/meetings/agendas', icon: <FileTextOutlined />, label: 'Agendas' });
schedulingChildren.push({ key: '/app/meetings/action-items', icon: <OrderedListOutlined />, label: 'Action Items' });
}
if (settings?.enableTicketedEvents) {
schedulingChildren.push({ key: '/app/events', icon: <TagOutlined />, label: 'Events' });

View File

@ -0,0 +1,128 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Flex, Button, Tag, Tooltip } from 'antd';
import {
OrderedListOutlined,
ReloadOutlined,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { ActionItem, ActionItemsListResponse } from '@/types/api';
import { ACTION_ITEM_STATUS_COLORS, ACTION_ITEM_PRIORITY_COLORS } from '@/types/api';
const { Text } = Typography;
function ActionItemRow({ item }: { item: ActionItem }) {
const navigate = useNavigate();
const isOverdue = item.dueDate && item.status !== 'done' && dayjs(item.dueDate).isBefore(dayjs(), 'day');
return (
<Flex
align="center"
gap={8}
onClick={() => navigate('/app/meetings/action-items')}
style={{
padding: '5px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.4,
cursor: 'pointer',
}}
>
{isOverdue && <ExclamationCircleOutlined style={{ color: '#ff4d4f', fontSize: 12, flexShrink: 0 }} />}
<Tooltip title={item.title}>
<Text
strong
style={{ fontSize: 13, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{item.title}
</Text>
</Tooltip>
<Tag
color={ACTION_ITEM_STATUS_COLORS[item.status]}
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
>
{item.status}
</Tag>
{item.priority !== 'normal' && (
<Tag
color={ACTION_ITEM_PRIORITY_COLORS[item.priority]}
style={{ fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '18px', flexShrink: 0 }}
>
{item.priority}
</Tag>
)}
{item.dueDate && (
<Text type="secondary" style={{ fontSize: 11, flexShrink: 0, color: isOverdue ? '#ff4d4f' : undefined }}>
{dayjs(item.dueDate).format('MMM D')}
</Text>
)}
</Flex>
);
}
export default function MyActionItemsCard() {
const navigate = useNavigate();
const [items, setItems] = useState<ActionItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ActionItemsListResponse>('/meetings/action-items/mine');
setItems(res.data.actionItems.slice(0, 5));
setTotal(res.data.pagination?.total ?? res.data.actionItems.length);
} catch {
// non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchItems();
const interval = setInterval(fetchItems, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchItems]);
const overdueCount = items.filter(i => i.dueDate && i.status !== 'done' && dayjs(i.dueDate).isBefore(dayjs(), 'day')).length;
return (
<Card
title={
<span style={{ fontSize: 14 }}>
<OrderedListOutlined style={{ marginRight: 6, fontSize: 15 }} />
My Action Items
{overdueCount > 0 && (
<Tag color="red" style={{ marginLeft: 6, fontSize: 10 }}>{overdueCount} overdue</Tag>
)}
</span>
}
size="small"
extra={
<Flex align="center" gap={6}>
{total > 5 && (
<Button type="link" size="small" onClick={() => navigate('/app/meetings/action-items')} style={{ fontSize: 12, padding: 0 }}>
+{total - 5} more
</Button>
)}
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 13 }} />} onClick={fetchItems} />
</Flex>
}
styles={{ body: { padding: '6px 14px 8px' } }}
>
{loading && items.length === 0 ? (
<div style={{ textAlign: 'center', padding: 12 }}><Spin size="small" /></div>
) : items.length > 0 ? (
<div>
{items.map(item => (
<ActionItemRow key={item.id} item={item} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 13, display: 'block', padding: '8px 0' }}>No action items</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,441 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Form,
Switch,
Popconfirm,
message,
Typography,
Row,
Col,
DatePicker,
Drawer,
Tooltip,
Grid,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
SearchOutlined,
EditOutlined,
CheckSquareOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type {
ActionItem,
ActionItemsListResponse,
ActionItemStatus,
ActionItemPriority,
User,
} from '@/types/api';
import {
ACTION_ITEM_STATUS_COLORS,
ACTION_ITEM_STATUS_LABELS,
ACTION_ITEM_PRIORITY_COLORS,
} from '@/types/api';
const { Text, Title } = Typography;
const PRIORITY_OPTIONS: { value: ActionItemPriority; label: string }[] = [
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' },
];
const STATUS_OPTIONS: { value: ActionItemStatus; label: string }[] = [
{ value: 'open', label: 'Open' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'done', label: 'Done' },
{ value: 'blocked', label: 'Blocked' },
];
export default function ActionItemsPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [items, setItems] = useState<ActionItem[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<ActionItemStatus | undefined>();
const [assigneeFilter, setAssigneeFilter] = useState<string | undefined>();
const [overdueOnly, setOverdueOnly] = useState(false);
const [myItemsMode, setMyItemsMode] = useState(false);
// User search for filters
const [userOptions, setUserOptions] = useState<{ value: string; label: string }[]>([]);
const [userSearchLoading, setUserSearchLoading] = useState(false);
// Drawer state
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ActionItem | null>(null);
const [form] = Form.useForm();
const [saving, setSaving] = useState(false);
// User search for form assignee
const [formUserOptions, setFormUserOptions] = useState<{ value: string; label: string }[]>([]);
const [formUserSearchLoading, setFormUserSearchLoading] = useState(false);
const drawerWidth = 500;
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const endpoint = myItemsMode ? '/meetings/action-items/mine' : '/meetings/action-items';
const params: Record<string, any> = { page: pagination.page, limit: pagination.limit };
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
if (assigneeFilter && !myItemsMode) params.assigneeUserId = assigneeFilter;
if (overdueOnly) params.overdue = true;
const { data } = await api.get<ActionItemsListResponse>(endpoint, { params });
setItems(data.actionItems);
setPagination((p) => ({ ...p, total: data.pagination.total }));
} catch {
message.error('Failed to load action items');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, search, statusFilter, assigneeFilter, overdueOnly, myItemsMode]);
useEffect(() => { fetchItems(); }, [fetchItems]);
const searchUsers = async (searchText: string, setOptions: typeof setUserOptions, setLoading: typeof setUserSearchLoading) => {
if (!searchText || searchText.length < 2) return;
setLoading(true);
try {
const { data } = await api.get<{ users: User[] }>('/users', { params: { search: searchText, limit: 50 } });
setOptions(data.users.map((u) => ({ value: u.id, label: u.name || u.email })));
} catch {
// silently fail
} finally {
setLoading(false);
}
};
const openCreate = () => {
setEditingItem(null);
form.resetFields();
form.setFieldsValue({ priority: 'normal' });
setFormUserOptions([]);
setDrawerOpen(true);
};
const openEdit = (item: ActionItem) => {
setEditingItem(item);
form.setFieldsValue({
title: item.title,
description: item.description || '',
assigneeUserId: item.assigneeUserId || undefined,
dueDate: item.dueDate ? dayjs(item.dueDate) : null,
priority: item.priority,
status: item.status,
agendaId: item.agendaId || undefined,
});
// Pre-populate assignee option if exists
if (item.assignee) {
setFormUserOptions([{ value: item.assignee.id, label: item.assignee.name || item.assignee.email }]);
} else {
setFormUserOptions([]);
}
setDrawerOpen(true);
};
const handleSave = async (values: any) => {
setSaving(true);
try {
const payload: Record<string, any> = {
title: values.title,
description: values.description || null,
assigneeUserId: values.assigneeUserId || null,
dueDate: values.dueDate?.toISOString() || null,
priority: values.priority,
};
if (editingItem) {
payload.status = values.status;
await api.put(`/meetings/action-items/${editingItem.id}`, payload);
message.success('Action item updated');
} else {
if (values.agendaId) payload.agendaId = values.agendaId;
await api.post('/meetings/action-items', payload);
message.success('Action item created');
}
setDrawerOpen(false);
setEditingItem(null);
form.resetFields();
fetchItems();
} catch {
message.error(editingItem ? 'Failed to update action item' : 'Failed to create action item');
} finally {
setSaving(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/meetings/action-items/${id}`);
message.success('Action item deleted');
fetchItems();
} catch {
message.error('Failed to delete action item');
}
};
const isOverdue = (item: ActionItem) => {
return item.dueDate && item.status !== 'done' && dayjs(item.dueDate).isBefore(dayjs(), 'day');
};
const columns: ColumnsType<ActionItem> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Button type="link" onClick={() => openEdit(record)} style={{ padding: 0 }}>
{title}
</Button>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 110,
render: (status: ActionItemStatus) => (
<Tag color={ACTION_ITEM_STATUS_COLORS[status]}>{ACTION_ITEM_STATUS_LABELS[status]}</Tag>
),
},
{
title: 'Priority',
dataIndex: 'priority',
key: 'priority',
width: 90,
render: (priority: ActionItemPriority) => (
<Tag color={ACTION_ITEM_PRIORITY_COLORS[priority]}>
{priority.charAt(0).toUpperCase() + priority.slice(1)}
</Tag>
),
},
{
title: 'Assignee',
key: 'assignee',
width: 140,
render: (_, record) => (
<Text>{record.assignee?.name || record.assignee?.email || '—'}</Text>
),
responsive: ['md'],
},
{
title: 'Due Date',
dataIndex: 'dueDate',
key: 'dueDate',
width: 120,
render: (date: string | null, record) => {
if (!date) return <Text type="secondary"></Text>;
const overdue = isOverdue(record);
return (
<Text style={overdue ? { color: '#ff4d4f', fontWeight: 600 } : undefined}>
{dayjs(date).format('MMM D, YYYY')}
</Text>
);
},
},
{
title: 'Meeting',
key: 'agenda',
width: 160,
render: (_, record) => (
record.agenda ? (
<Text type="secondary" ellipsis style={{ maxWidth: 140 }}>
{record.agenda.title}
</Text>
) : (
<Text type="secondary"></Text>
)
),
responsive: ['lg'],
},
{
title: 'Actions',
key: 'actions',
width: 100,
render: (_, record) => (
<Space size="small">
<Tooltip title="Edit">
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)} />
</Tooltip>
<Popconfirm title="Delete this action item?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
return (
<div style={{ padding: screens.md ? 24 : 16 }}>
<div
style={{
marginRight: isMobile ? 0 : drawerOpen ? drawerWidth : 0,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Title level={4} style={{ margin: 0 }}>
<CheckSquareOutlined style={{ marginRight: 8 }} />
Action Items
</Title>
</Col>
<Col>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{screens.md ? 'New Action Item' : 'New'}
</Button>
</Col>
</Row>
{/* Filters */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={6}>
<Input
placeholder="Search action items..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
/>
</Col>
<Col xs={12} sm={6} md={4}>
<Select
placeholder="Status"
style={{ width: '100%' }}
allowClear
value={statusFilter}
onChange={setStatusFilter}
options={STATUS_OPTIONS}
/>
</Col>
<Col xs={12} sm={6} md={5}>
<Select
placeholder="Assignee"
style={{ width: '100%' }}
allowClear
showSearch
filterOption={false}
value={assigneeFilter}
onChange={setAssigneeFilter}
onSearch={(v) => searchUsers(v, setUserOptions, setUserSearchLoading)}
loading={userSearchLoading}
options={userOptions}
notFoundContent={userSearchLoading ? 'Searching...' : null}
/>
</Col>
<Col>
<Space>
<Switch
checked={overdueOnly}
onChange={setOverdueOnly}
checkedChildren="Overdue"
unCheckedChildren="All"
/>
<Button
type={myItemsMode ? 'primary' : 'default'}
icon={<UserOutlined />}
onClick={() => setMyItemsMode(!myItemsMode)}
>
{screens.md ? 'My Items' : 'Mine'}
</Button>
</Space>
</Col>
</Row>
<Table
dataSource={items}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: false,
onChange: (page) => setPagination((p) => ({ ...p, page })),
}}
size="small"
/>
</div>
{/* Create / Edit Drawer */}
<Drawer
title={editingItem ? 'Edit Action Item' : 'New Action Item'}
open={drawerOpen}
onClose={() => { setDrawerOpen(false); setEditingItem(null); form.resetFields(); }}
width={isMobile ? '100%' : drawerWidth}
mask={false}
destroyOnHidden
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={() => { setDrawerOpen(false); setEditingItem(null); form.resetFields(); }} disabled={saving}>
Cancel
</Button>
<Button type="primary" loading={saving} onClick={() => form.submit()}>
{editingItem ? 'Save' : 'Create'}
</Button>
</Space>
}
>
<Form
form={form}
layout="vertical"
onFinish={handleSave}
initialValues={{ priority: 'normal' }}
>
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
<Input placeholder="What needs to be done?" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} placeholder="Additional details..." />
</Form.Item>
<Form.Item name="assigneeUserId" label="Assignee">
<Select
placeholder="Search for a user..."
allowClear
showSearch
filterOption={false}
onSearch={(v) => searchUsers(v, setFormUserOptions, setFormUserSearchLoading)}
loading={formUserSearchLoading}
options={formUserOptions}
notFoundContent={formUserSearchLoading ? 'Searching...' : null}
/>
</Form.Item>
<Form.Item name="dueDate" label="Due Date">
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="priority" label="Priority">
<Select options={PRIORITY_OPTIONS} />
</Form.Item>
{editingItem && (
<Form.Item name="status" label="Status">
<Select options={STATUS_OPTIONS} />
</Form.Item>
)}
{!editingItem && (
<Form.Item name="agendaId" label="Meeting (optional)">
<Input placeholder="Agenda ID (optional)" />
</Form.Item>
)}
</Form>
</Drawer>
</div>
);
}

View File

@ -61,6 +61,7 @@ import TopVideosCard from '@/components/dashboard/TopVideosCard';
import RecentCommentsCard from '@/components/dashboard/RecentCommentsCard';
import DocsAnalyticsCard from '@/components/dashboard/DocsAnalyticsCard';
import UpcomingShiftsCard from '@/components/dashboard/UpcomingShiftsCard';
import MyActionItemsCard from '@/components/dashboard/MyActionItemsCard';
import CampaignEffectivenessCard from '@/components/dashboard/CampaignEffectivenessCard';
import RecentSignupsCard from '@/components/dashboard/RecentSignupsCard';
import NewsletterStatsCard from '@/components/dashboard/NewsletterStatsCard';
@ -686,6 +687,11 @@ export default function DashboardPage() {
<UpcomingShiftsCard />
</div>
)}
{settings?.enableMeetingPlanner && (
<div className="db-mi">
<MyActionItemsCard />
</div>
)}
{showMeet && (
<div className="db-mi">
<UpcomingMeetingsCard />

View File

@ -0,0 +1,817 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table,
Button,
Input,
Select,
Tag,
Space,
Form,
Popconfirm,
message,
Typography,
Row,
Col,
Drawer,
Card,
Grid,
InputNumber,
Switch,
List,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
SearchOutlined,
FileTextOutlined,
EditOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
CheckCircleOutlined,
} from '@ant-design/icons';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type {
MeetingAgenda,
ActionItem,
AgendasListResponse,
AgendaStatus,
ActionItemStatus,
AgendaItem,
} from '@/types/api';
import {
ACTION_ITEM_STATUS_COLORS,
ACTION_ITEM_STATUS_LABELS,
ACTION_ITEM_PRIORITY_COLORS,
} from '@/types/api';
const { Text, Title } = Typography;
const { TextArea } = Input;
const AGENDA_STATUS_COLORS: Record<AgendaStatus, string> = {
draft: 'default',
active: 'blue',
completed: 'green',
};
const AGENDA_STATUS_LABELS: Record<AgendaStatus, string> = {
draft: 'Draft',
active: 'Active',
completed: 'Completed',
};
export default function MeetingAgendaPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
// Main table state
const [agendas, setAgendas] = useState<MeetingAgenda[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<AgendaStatus | undefined>();
// Create drawer
const [createOpen, setCreateOpen] = useState(false);
const [createForm] = Form.useForm();
const [creating, setCreating] = useState(false);
// Detail drawer
const [detailOpen, setDetailOpen] = useState(false);
const [selectedAgenda, setSelectedAgenda] = useState<MeetingAgenda | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// Minutes editing state
const [minutesNotes, setMinutesNotes] = useState('');
const [minutesDecisions, setMinutesDecisions] = useState<Array<{ id: string; text: string; passed: boolean }>>([]);
const [minutesAttendees, setMinutesAttendees] = useState<Array<{ name: string; pronouns?: string; userId?: string }>>([]);
const [savingMinutes, setSavingMinutes] = useState(false);
const [approvingMinutes, setApprovingMinutes] = useState(false);
// Agenda items editing in detail drawer
const [editingItems, setEditingItems] = useState<AgendaItem[]>([]);
const [savingItems, setSavingItems] = useState(false);
// New decision / attendee inputs
const [newDecisionText, setNewDecisionText] = useState('');
const [newAttendeeName, setNewAttendeeName] = useState('');
// Polls and shifts for selects
const [pollOptions, setPollOptions] = useState<Array<{ value: string; label: string }>>([]);
const [shiftOptions, setShiftOptions] = useState<Array<{ value: string; label: string }>>([]);
const fetchAgendas = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, any> = { page: pagination.page, limit: pagination.limit };
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get<AgendasListResponse>('/meetings/agendas', { params });
setAgendas(data.agendas);
setPagination((p) => ({ ...p, total: data.pagination.total }));
} catch {
message.error('Failed to load agendas');
} finally {
setLoading(false);
}
}, [pagination.page, pagination.limit, search, statusFilter]);
useEffect(() => { fetchAgendas(); }, [fetchAgendas]);
// Fetch poll/shift options when create drawer opens
useEffect(() => {
if (!createOpen) return;
const fetchOptions = async () => {
try {
const [pollsRes, shiftsRes] = await Promise.all([
api.get('/meeting-planner', { params: { limit: 100 } }).catch(() => ({ data: { polls: [] } })),
api.get('/map/shifts', { params: { limit: 100 } }).catch(() => ({ data: { shifts: [] } })),
]);
setPollOptions(
(pollsRes.data.polls || []).map((p: any) => ({ value: p.id, label: p.title }))
);
setShiftOptions(
(shiftsRes.data.shifts || []).map((s: any) => ({
value: s.id,
label: `${s.title} - ${dayjs(s.date).format('MMM D')}`,
}))
);
} catch {
// Silent — selects will just be empty
}
};
fetchOptions();
}, [createOpen]);
const fetchAgendaDetail = async (id: string) => {
setDetailLoading(true);
try {
const { data } = await api.get<MeetingAgenda>(`/meetings/agendas/${id}`);
setSelectedAgenda(data);
setEditingItems(data.items || []);
// Populate minutes state
if (data.minutes) {
setMinutesNotes(data.minutes.notes || '');
setMinutesDecisions(data.minutes.decisions || []);
setMinutesAttendees(data.minutes.attendees || []);
} else {
setMinutesNotes('');
setMinutesDecisions([]);
setMinutesAttendees([]);
}
setDetailOpen(true);
} catch {
message.error('Failed to load agenda details');
} finally {
setDetailLoading(false);
}
};
const handleCreate = async (values: any) => {
setCreating(true);
try {
await api.post('/meetings/agendas', {
title: values.title,
pollId: values.pollId || null,
shiftId: values.shiftId || null,
});
message.success('Agenda created');
setCreateOpen(false);
createForm.resetFields();
fetchAgendas();
} catch {
message.error('Failed to create agenda');
} finally {
setCreating(false);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/meetings/agendas/${id}`);
message.success('Agenda deleted');
fetchAgendas();
} catch {
message.error('Failed to delete agenda');
}
};
// Agenda items management
const handleMoveItem = (index: number, direction: 'up' | 'down') => {
const items = [...editingItems];
const swapIndex = direction === 'up' ? index - 1 : index + 1;
if (swapIndex < 0 || swapIndex >= items.length) return;
[items[index], items[swapIndex]] = [items[swapIndex]!, items[index]!];
// Recalculate order
items.forEach((item, i) => { item.order = i; });
setEditingItems(items);
};
const handleAddItem = () => {
const newItem: AgendaItem = {
id: `temp-${Date.now()}`,
title: '',
durationMinutes: 5,
order: editingItems.length,
};
setEditingItems([...editingItems, newItem]);
};
const handleRemoveItem = (index: number) => {
const items = editingItems.filter((_, i) => i !== index);
items.forEach((item, i) => { item.order = i; });
setEditingItems(items);
};
const handleUpdateItem = (index: number, field: keyof AgendaItem, value: any) => {
const items = [...editingItems];
(items[index] as any)[field] = value;
setEditingItems(items);
};
const handleSaveItems = async () => {
if (!selectedAgenda) return;
setSavingItems(true);
try {
const items = editingItems.map((item, i) => ({
id: item.id.startsWith('temp-') ? undefined : item.id,
title: item.title,
durationMinutes: item.durationMinutes,
order: i,
}));
await api.put(`/meetings/agendas/${selectedAgenda.id}`, { items });
message.success('Agenda items saved');
fetchAgendaDetail(selectedAgenda.id);
fetchAgendas();
} catch {
message.error('Failed to save agenda items');
} finally {
setSavingItems(false);
}
};
// Minutes management
const handleSaveMinutes = async () => {
if (!selectedAgenda) return;
setSavingMinutes(true);
try {
const payload = {
notes: minutesNotes,
decisions: minutesDecisions,
attendees: minutesAttendees,
};
if (selectedAgenda.minutes) {
await api.put(`/meetings/agendas/${selectedAgenda.id}/minutes`, payload);
} else {
await api.post(`/meetings/agendas/${selectedAgenda.id}/minutes`, payload);
}
message.success('Minutes saved');
fetchAgendaDetail(selectedAgenda.id);
} catch {
message.error('Failed to save minutes');
} finally {
setSavingMinutes(false);
}
};
const handleApproveMinutes = async () => {
if (!selectedAgenda) return;
setApprovingMinutes(true);
try {
await api.post(`/meetings/agendas/${selectedAgenda.id}/minutes/approve`);
message.success('Minutes approved');
fetchAgendaDetail(selectedAgenda.id);
} catch {
message.error('Failed to approve minutes');
} finally {
setApprovingMinutes(false);
}
};
// Action item inline status change
const handleActionItemStatusChange = async (actionItem: ActionItem, newStatus: ActionItemStatus) => {
try {
await api.put(`/meetings/agendas/${actionItem.agendaId}/action-items/${actionItem.id}`, {
status: newStatus,
});
message.success('Action item updated');
if (selectedAgenda) fetchAgendaDetail(selectedAgenda.id);
} catch {
message.error('Failed to update action item');
}
};
// Add decision
const handleAddDecision = () => {
if (!newDecisionText.trim()) return;
setMinutesDecisions([
...minutesDecisions,
{ id: `d-${Date.now()}`, text: newDecisionText.trim(), passed: false },
]);
setNewDecisionText('');
};
const handleRemoveDecision = (index: number) => {
setMinutesDecisions(minutesDecisions.filter((_, i) => i !== index));
};
const handleToggleDecisionPassed = (index: number) => {
const decisions = [...minutesDecisions];
decisions[index] = { ...decisions[index]!, passed: !decisions[index]!.passed };
setMinutesDecisions(decisions);
};
// Add attendee
const handleAddAttendee = () => {
if (!newAttendeeName.trim()) return;
setMinutesAttendees([...minutesAttendees, { name: newAttendeeName.trim() }]);
setNewAttendeeName('');
};
const handleRemoveAttendee = (index: number) => {
setMinutesAttendees(minutesAttendees.filter((_, i) => i !== index));
};
const getLinkedLabel = (record: MeetingAgenda) => {
if (record.pollId) return <Tag color="purple">Poll</Tag>;
if (record.shiftId) return <Tag color="cyan">Shift</Tag>;
return <Text type="secondary">None</Text>;
};
const columns: ColumnsType<MeetingAgenda> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Button type="link" onClick={() => fetchAgendaDetail(record.id)} style={{ padding: 0 }}>
{title}
</Button>
),
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 110,
render: (status: AgendaStatus) => (
<Tag color={AGENDA_STATUS_COLORS[status]}>{AGENDA_STATUS_LABELS[status]}</Tag>
),
},
{
title: 'Linked To',
key: 'linked',
width: 100,
render: (_, record) => getLinkedLabel(record),
responsive: ['md'],
},
{
title: 'Items',
key: 'items',
width: 70,
render: (_, record) => record.items?.length ?? 0,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 120,
render: (date: string) => dayjs(date).format('MMM D, YYYY'),
responsive: ['md'],
},
{
title: 'Actions',
key: 'actions',
width: 100,
render: (_, record) => (
<Space size="small">
<Button size="small" icon={<EditOutlined />} onClick={() => fetchAgendaDetail(record.id)} />
<Popconfirm title="Delete this agenda?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const getActiveDrawerWidth = () => {
if (createOpen) return 560;
if (detailOpen) return 720;
return 0;
};
const activeDrawerWidth = getActiveDrawerWidth();
const actionItemColumns: ColumnsType<ActionItem> = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
ellipsis: true,
},
{
title: 'Assignee',
key: 'assignee',
width: 120,
render: (_, record) => record.assignee?.name || record.assignee?.email || <Text type="secondary">Unassigned</Text>,
},
{
title: 'Priority',
dataIndex: 'priority',
key: 'priority',
width: 90,
render: (priority) => (
<Tag color={ACTION_ITEM_PRIORITY_COLORS[priority as keyof typeof ACTION_ITEM_PRIORITY_COLORS]}>
{priority}
</Tag>
),
},
{
title: 'Due',
dataIndex: 'dueDate',
key: 'dueDate',
width: 100,
render: (date: string | null) => date ? dayjs(date).format('MMM D') : '-',
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 130,
render: (status: ActionItemStatus, record) => (
<Select
size="small"
value={status}
style={{ width: '100%' }}
onChange={(val) => handleActionItemStatusChange(record, val)}
options={Object.entries(ACTION_ITEM_STATUS_LABELS).map(([value, label]) => ({
value,
label: <Tag color={ACTION_ITEM_STATUS_COLORS[value as ActionItemStatus]}>{label}</Tag>,
}))}
/>
),
},
];
return (
<div style={{ padding: screens.md ? 24 : 16 }}>
<div
style={{
marginRight: isMobile ? 0 : activeDrawerWidth,
transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)',
}}
>
<Row justify="space-between" align="middle" style={{ marginBottom: 16 }}>
<Col>
<Title level={4} style={{ margin: 0 }}>
<FileTextOutlined style={{ marginRight: 8 }} />
Meeting Agendas
</Title>
</Col>
<Col>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setCreateOpen(true)}>
{screens.md ? 'Create Agenda' : 'New'}
</Button>
</Col>
</Row>
{/* Filters */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
<Col xs={24} sm={12} md={8}>
<Input
placeholder="Search agendas..."
prefix={<SearchOutlined />}
value={search}
onChange={(e) => setSearch(e.target.value)}
allowClear
/>
</Col>
<Col xs={24} sm={12} md={6}>
<Select
placeholder="Filter by status"
style={{ width: '100%' }}
allowClear
value={statusFilter}
onChange={setStatusFilter}
options={Object.entries(AGENDA_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Col>
</Row>
<Table
dataSource={agendas}
columns={columns}
rowKey="id"
loading={loading}
pagination={{
current: pagination.page,
pageSize: pagination.limit,
total: pagination.total,
showSizeChanger: false,
onChange: (page) => setPagination((p) => ({ ...p, page })),
}}
size="small"
/>
</div>
{/* Create Agenda Drawer */}
<Drawer
title="Create Agenda"
open={createOpen}
onClose={() => { setCreateOpen(false); createForm.resetFields(); }}
width={isMobile ? '100%' : 560}
mask={false}
destroyOnHidden
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={
<Space>
<Button onClick={() => { setCreateOpen(false); createForm.resetFields(); }} disabled={creating}>
Cancel
</Button>
<Button type="primary" loading={creating} onClick={() => createForm.submit()}>
Create
</Button>
</Space>
}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate}>
<Form.Item name="title" label="Title" rules={[{ required: true, message: 'Title is required' }]}>
<Input placeholder="e.g., Weekly Team Meeting" />
</Form.Item>
<Form.Item name="pollId" label="Link to Poll (optional)">
<Select
placeholder="Select a poll"
allowClear
showSearch
optionFilterProp="label"
options={pollOptions}
/>
</Form.Item>
<Form.Item name="shiftId" label="Link to Shift (optional)">
<Select
placeholder="Select a shift"
allowClear
showSearch
optionFilterProp="label"
options={shiftOptions}
/>
</Form.Item>
</Form>
</Drawer>
{/* Detail Drawer */}
<Drawer
title={selectedAgenda?.title || 'Agenda Details'}
open={detailOpen}
onClose={() => { setDetailOpen(false); setSelectedAgenda(null); }}
width={isMobile ? '100%' : 720}
loading={detailLoading}
mask={false}
destroyOnHidden
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
>
{selectedAgenda && (
<>
{/* Metadata */}
<Row gutter={[16, 8]} style={{ marginBottom: 16 }}>
<Col>
<Tag color={AGENDA_STATUS_COLORS[selectedAgenda.status]}>
{AGENDA_STATUS_LABELS[selectedAgenda.status]}
</Tag>
</Col>
<Col>{getLinkedLabel(selectedAgenda)}</Col>
<Col>
<Text type="secondary">
Created {dayjs(selectedAgenda.createdAt).format('MMM D, YYYY')}
{selectedAgenda.createdBy?.name ? ` by ${selectedAgenda.createdBy.name}` : ''}
</Text>
</Col>
</Row>
{/* Agenda Items */}
<Card
size="small"
title="Agenda Items"
style={{ marginBottom: 16 }}
extra={
<Space>
<Button size="small" icon={<PlusOutlined />} onClick={handleAddItem}>
Add
</Button>
<Button
size="small"
type="primary"
loading={savingItems}
onClick={handleSaveItems}
disabled={editingItems.length === 0}
>
Save Items
</Button>
</Space>
}
>
{editingItems.length === 0 ? (
<Text type="secondary">No agenda items yet. Click Add to get started.</Text>
) : (
editingItems.map((item, index) => (
<Row
key={item.id}
gutter={8}
align="middle"
style={{
marginBottom: 8,
padding: '8px 0',
borderBottom: index < editingItems.length - 1 ? '1px solid #f0f0f0' : undefined,
}}
>
<Col flex="none">
<Space direction="vertical" size={0}>
<Button
size="small"
type="text"
icon={<ArrowUpOutlined />}
disabled={index === 0}
onClick={() => handleMoveItem(index, 'up')}
/>
<Button
size="small"
type="text"
icon={<ArrowDownOutlined />}
disabled={index === editingItems.length - 1}
onClick={() => handleMoveItem(index, 'down')}
/>
</Space>
</Col>
<Col flex="auto">
<Input
placeholder="Item title"
value={item.title}
onChange={(e) => handleUpdateItem(index, 'title', e.target.value)}
size="small"
/>
</Col>
<Col flex="none">
<InputNumber
min={1}
max={480}
value={item.durationMinutes}
onChange={(val) => handleUpdateItem(index, 'durationMinutes', val)}
size="small"
style={{ width: 70 }}
addonAfter="min"
/>
</Col>
<Col flex="none">
<Button
size="small"
danger
type="text"
icon={<DeleteOutlined />}
onClick={() => handleRemoveItem(index)}
/>
</Col>
</Row>
))
)}
{editingItems.length > 0 && (
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
Total: {editingItems.reduce((sum, item) => sum + (item.durationMinutes || 0), 0)} min
</Text>
)}
</Card>
{/* Minutes Section */}
<Card
size="small"
title="Minutes"
style={{ marginBottom: 16 }}
extra={
<Space>
<Button size="small" type="primary" loading={savingMinutes} onClick={handleSaveMinutes}>
Save Minutes
</Button>
{selectedAgenda.minutes && !selectedAgenda.minutes.approvedAt && (
<Popconfirm title="Approve these minutes?" onConfirm={handleApproveMinutes}>
<Button
size="small"
icon={<CheckCircleOutlined />}
loading={approvingMinutes}
>
Approve
</Button>
</Popconfirm>
)}
{selectedAgenda.minutes?.approvedAt && (
<Tag color="green" icon={<CheckCircleOutlined />}>
Approved {dayjs(selectedAgenda.minutes.approvedAt).format('MMM D')}
</Tag>
)}
</Space>
}
>
{/* Notes */}
<Text strong style={{ display: 'block', marginBottom: 4 }}>Notes</Text>
<TextArea
rows={4}
value={minutesNotes}
onChange={(e) => setMinutesNotes(e.target.value)}
placeholder="Meeting notes..."
style={{ marginBottom: 16 }}
/>
{/* Decisions */}
<Text strong style={{ display: 'block', marginBottom: 4 }}>Decisions</Text>
<List
size="small"
dataSource={minutesDecisions}
locale={{ emptyText: 'No decisions recorded' }}
renderItem={(decision, index) => (
<List.Item
actions={[
<Switch
key="passed"
size="small"
checked={decision.passed}
onChange={() => handleToggleDecisionPassed(index)}
checkedChildren="Passed"
unCheckedChildren="Not passed"
/>,
<Button
key="del"
size="small"
danger
type="text"
icon={<DeleteOutlined />}
onClick={() => handleRemoveDecision(index)}
/>,
]}
>
<Text>{decision.text}</Text>
</List.Item>
)}
/>
<Row gutter={8} style={{ marginTop: 8, marginBottom: 16 }}>
<Col flex="auto">
<Input
size="small"
placeholder="Add a decision..."
value={newDecisionText}
onChange={(e) => setNewDecisionText(e.target.value)}
onPressEnter={handleAddDecision}
/>
</Col>
<Col>
<Button size="small" icon={<PlusOutlined />} onClick={handleAddDecision}>
Add
</Button>
</Col>
</Row>
{/* Attendees */}
<Text strong style={{ display: 'block', marginBottom: 4 }}>Attendees</Text>
<Space wrap style={{ marginBottom: 8 }}>
{minutesAttendees.map((attendee, index) => (
<Tag
key={index}
closable
onClose={() => handleRemoveAttendee(index)}
>
{attendee.name}
</Tag>
))}
</Space>
<Row gutter={8}>
<Col flex="auto">
<Input
size="small"
placeholder="Add attendee name..."
value={newAttendeeName}
onChange={(e) => setNewAttendeeName(e.target.value)}
onPressEnter={handleAddAttendee}
/>
</Col>
<Col>
<Button size="small" icon={<PlusOutlined />} onClick={handleAddAttendee}>
Add
</Button>
</Col>
</Row>
</Card>
{/* Action Items */}
<Card size="small" title={`Action Items (${selectedAgenda.actionItems?.length ?? 0})`}>
<Table
dataSource={selectedAgenda.actionItems || []}
columns={actionItemColumns}
rowKey="id"
size="small"
pagination={false}
locale={{ emptyText: 'No action items' }}
/>
</Card>
</>
)}
</Drawer>
</div>
);
}

View File

@ -21,6 +21,7 @@ import {
Grid,
Divider,
Modal,
InputNumber,
} from 'antd';
import {
PlusOutlined,
@ -95,6 +96,10 @@ export default function MeetingPlannerPage() {
const [convertForm] = Form.useForm();
const [converting, setConverting] = useState(false);
// Prep checklist (participant needs aggregate)
const [needsSummary, setNeedsSummary] = useState<any>(null);
const [needsLoading, setNeedsLoading] = useState(false);
// Edit drawer
const [editOpen, setEditOpen] = useState(false);
const [editForm] = Form.useForm();
@ -128,6 +133,7 @@ export default function MeetingPlannerPage() {
const { data } = await api.get<PollDetailResponse>(`/meeting-planner/${id}`);
setSelectedPoll(data);
setDetailOpen(true);
fetchNeedsSummary(data);
} catch {
message.error('Failed to load poll details');
} finally {
@ -135,6 +141,29 @@ export default function MeetingPlannerPage() {
}
};
const fetchNeedsSummary = async (pollDetail: PollDetailResponse) => {
if (pollDetail.status !== 'FINALIZED') { setNeedsSummary(null); return; }
setNeedsLoading(true);
try {
// Collect userIds and contactIds from YES voters on finalized option
const yesVotes = pollDetail.votes?.filter(
(v) => v.optionId === pollDetail.finalizedOptionId && v.value === 'YES'
) || [];
const userIds = yesVotes.map((v) => v.userId).filter(Boolean);
const contactIds = yesVotes.map((v) => v.contactId).filter(Boolean);
if (userIds.length === 0 && contactIds.length === 0) { setNeedsSummary(null); setNeedsLoading(false); return; }
const params: string[] = [];
if (userIds.length) params.push(`userIds=${userIds.join(',')}`);
if (contactIds.length) params.push(`contactIds=${contactIds.join(',')}`);
const { data } = await api.get(`/people/needs/aggregate?${params.join('&')}`);
setNeedsSummary(data.summary);
} catch {
setNeedsSummary(null);
} finally {
setNeedsLoading(false);
}
};
const handleCreate = async (values: any) => {
setCreating(true);
try {
@ -152,6 +181,13 @@ export default function MeetingPlannerPage() {
isPrivate: values.isPrivate ?? false,
notifyOnVote: values.notifyOnVote ?? true,
votingDeadline: values.votingDeadline?.toISOString(),
autoFinalize: values.autoFinalize ?? false,
autoFinalizeThreshold: values.autoFinalizeThreshold ?? null,
autoConvertToCalendar: values.autoConvertToCalendar ?? false,
autoConvertToGancio: values.autoConvertToGancio ?? false,
autoConvertToShift: values.autoConvertToShift ?? false,
tieBreaker: values.tieBreaker ?? 'earliest',
autoEnrollVoters: values.autoEnrollVoters ?? true,
options,
});
message.success('Poll created');
@ -612,6 +648,12 @@ export default function MeetingPlannerPage() {
allowAnonymous: true,
isPrivate: false,
notifyOnVote: true,
autoFinalize: false,
autoConvertToCalendar: false,
autoConvertToGancio: false,
autoConvertToShift: false,
tieBreaker: 'earliest',
autoEnrollVoters: true,
options: [
{ date: null, startTime: null, endTime: null },
],
@ -692,6 +734,73 @@ export default function MeetingPlannerPage() {
<Form.Item name="votingDeadline" label="Voting Deadline (optional)">
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Divider>Auto-Finalize</Divider>
<Form.Item name="autoFinalize" label="Automatically finalize this poll" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.autoFinalize !== cur.autoFinalize}>
{({ getFieldValue }) =>
getFieldValue('autoFinalize') && (
<>
<Form.Item
name="autoFinalizeThreshold"
label="Vote threshold (optional)"
tooltip="Finalize when this many people vote YES on any option"
>
<InputNumber min={1} max={100} placeholder="e.g., 5" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="tieBreaker" label="Tie-breaker strategy">
<Select
options={[
{ value: 'earliest', label: 'Pick earliest date' },
{ value: 'organizer_choice', label: 'Let me choose manually' },
]}
/>
</Form.Item>
<Divider orientation="left" plain style={{ fontSize: 12 }}>
Auto-Convert On Finalize
</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="autoConvertToCalendar" label="Calendar" valuePropName="checked">
<Switch size="small" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="autoConvertToGancio" label="Public Event" valuePropName="checked">
<Switch size="small" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="autoConvertToShift" label="Shift" valuePropName="checked">
<Switch size="small" />
</Form.Item>
</Col>
</Row>
<Form.Item noStyle shouldUpdate={(prev, cur) => prev.autoConvertToShift !== cur.autoConvertToShift}>
{({ getFieldValue: gfv }) =>
gfv('autoConvertToShift') && (
<Form.Item
name="autoEnrollVoters"
label="Auto-enroll YES voters into shift"
valuePropName="checked"
tooltip="When enabled, voters who said YES are automatically signed up. When disabled, they receive a confirmation email instead."
>
<Switch size="small" />
</Form.Item>
)
}
</Form.Item>
</>
)
}
</Form.Item>
</Form>
</Drawer>
@ -743,6 +852,29 @@ export default function MeetingPlannerPage() {
Location: {selectedPoll.location}
</Text>
)}
{selectedPoll.autoFinalize && (
<Card size="small" style={{ marginBottom: 16, background: '#f6ffed', borderColor: '#b7eb8f' }}>
<Space wrap>
<Tag color="green">Auto-Finalize</Tag>
{selectedPoll.autoFinalizeThreshold && (
<Text type="secondary">Threshold: {selectedPoll.autoFinalizeThreshold} YES votes</Text>
)}
{selectedPoll.votingDeadline && (
<Text type="secondary">Deadline: {dayjs(selectedPoll.votingDeadline).format('YYYY-MM-DD HH:mm')}</Text>
)}
<Text type="secondary">Tie: {selectedPoll.tieBreaker === 'earliest' ? 'earliest date' : 'manual'}</Text>
{selectedPoll.autoConvertToCalendar && <Tag>Calendar</Tag>}
{selectedPoll.autoConvertToGancio && <Tag>Gancio</Tag>}
{selectedPoll.autoConvertToShift && <Tag>Shift</Tag>}
{selectedPoll.autoConvertToShift && selectedPoll.autoEnrollVoters && <Tag color="cyan">Auto-Enroll</Tag>}
</Space>
</Card>
)}
{selectedPoll.convertedCalendarItemId && (
<Tag color="purple" icon={<CheckCircleOutlined />} style={{ marginBottom: 8 }}>
Converted to Calendar Item
</Tag>
)}
{/* Voting Matrix */}
<Card size="small" title="Voting Matrix" style={{ marginBottom: 16 }}>
@ -802,6 +934,69 @@ export default function MeetingPlannerPage() {
</Space>
</Card>
{/* Prep Checklist — participant needs summary for finalized polls */}
{selectedPoll.status === 'FINALIZED' && (
<Card
size="small"
title="Prep Checklist"
loading={needsLoading}
style={{ marginBottom: 16 }}
>
{!needsSummary || needsSummary.total === 0 ? (
<Text type="secondary" style={{ fontSize: 13 }}>
No participant needs reported yet.
</Text>
) : (
<div style={{ fontSize: 13 }}>
<Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>
{needsSummary.total} participant(s) reported needs
</Text>
{(needsSummary.accessibility.wheelchair > 0 || needsSummary.accessibility.groundFloor > 0 ||
needsSummary.accessibility.hearingLoop > 0 || needsSummary.accessibility.signLanguage > 0 ||
needsSummary.accessibility.other > 0) && (
<div style={{ marginBottom: 8 }}>
<Text strong>Accessibility:</Text>
<div style={{ paddingLeft: 12 }}>
{needsSummary.accessibility.wheelchair > 0 && <div>Wheelchair access: {needsSummary.accessibility.wheelchair}</div>}
{needsSummary.accessibility.groundFloor > 0 && <div>Ground floor: {needsSummary.accessibility.groundFloor}</div>}
{needsSummary.accessibility.hearingLoop > 0 && <div>Hearing loop: {needsSummary.accessibility.hearingLoop}</div>}
{needsSummary.accessibility.signLanguage > 0 && <div>Sign language: {needsSummary.accessibility.signLanguage}</div>}
{needsSummary.accessibility.other > 0 && <div>Other: {needsSummary.accessibility.other}</div>}
</div>
</div>
)}
{(needsSummary.dietary.vegan > 0 || needsSummary.dietary.vegetarian > 0 ||
needsSummary.dietary.glutenFree > 0 || needsSummary.dietary.halal > 0 ||
needsSummary.dietary.kosher > 0 || needsSummary.dietary.nutAllergy > 0 ||
needsSummary.dietary.other > 0) && (
<div style={{ marginBottom: 8 }}>
<Text strong>Dietary:</Text>
<div style={{ paddingLeft: 12 }}>
{needsSummary.dietary.vegan > 0 && <div>Vegan: {needsSummary.dietary.vegan}</div>}
{needsSummary.dietary.vegetarian > 0 && <div>Vegetarian: {needsSummary.dietary.vegetarian}</div>}
{needsSummary.dietary.glutenFree > 0 && <div>Gluten-free: {needsSummary.dietary.glutenFree}</div>}
{needsSummary.dietary.halal > 0 && <div>Halal: {needsSummary.dietary.halal}</div>}
{needsSummary.dietary.kosher > 0 && <div>Kosher: {needsSummary.dietary.kosher}</div>}
{needsSummary.dietary.nutAllergy > 0 && <div>Nut allergy: {needsSummary.dietary.nutAllergy}</div>}
{needsSummary.dietary.other > 0 && <div>Other: {needsSummary.dietary.other}</div>}
</div>
</div>
)}
{needsSummary.childcare > 0 && <div><Text strong>Childcare:</Text> {needsSummary.childcare}</div>}
{needsSummary.transportation > 0 && <div><Text strong>Transportation:</Text> {needsSummary.transportation}</div>}
{needsSummary.translation > 0 && (
<div>
<Text strong>Translation:</Text> {needsSummary.translation}
{Object.keys(needsSummary.languages).length > 0 && (
<span> ({Object.entries(needsSummary.languages).map(([lang, count]) => `${lang}: ${count}`).join(', ')})</span>
)}
</div>
)}
</div>
)}
</Card>
)}
{/* Comments */}
{selectedPoll.comments && selectedPoll.comments.length > 0 && (
<Card size="small" title={`Comments (${selectedPoll.comments.length})`}>

View File

@ -798,6 +798,9 @@ export default function UsersPage() {
<Form.Item name="phone" label="Phone">
<Input placeholder="+1 555 000 0000" />
</Form.Item>
<Form.Item name="pronouns" label="Pronouns">
<Input placeholder="e.g., they/them, she/her, he/him" />
</Form.Item>
<Form.Item name="roles" label="Roles" initialValue={['USER']}>
<Select
mode="multiple"
@ -887,6 +890,9 @@ export default function UsersPage() {
<Form.Item name="phone" label="Phone">
<Input placeholder="+1 555 000 0000" />
</Form.Item>
<Form.Item name="pronouns" label="Pronouns">
<Input placeholder="e.g., they/them, she/her, he/him" />
</Form.Item>
<Form.Item name="roles" label="Roles">
<Select
mode="multiple"

View File

@ -8,6 +8,8 @@ import {
Tag,
Input,
Radio,
Checkbox,
Select,
message,
Spin,
Result,
@ -64,6 +66,14 @@ export default function SchedulingPollPage() {
const [hasVoted, setHasVoted] = useState(false);
const [voteSuccess, setVoteSuccess] = useState(false);
// Participant needs state
const [needsExpanded, setNeedsExpanded] = useState(false);
const [participantNeeds, setParticipantNeeds] = useState<Record<string, any>>({});
const updateNeed = (key: string, value: any) => {
setParticipantNeeds((prev) => ({ ...prev, [key]: value }));
};
// Comment form state
const [commentName, setCommentName] = useState(user?.name || '');
const [commentContent, setCommentContent] = useState('');
@ -125,11 +135,13 @@ export default function SchedulingPollPage() {
setSubmitting(true);
try {
const storedToken = localStorage.getItem(VOTER_TOKEN_KEY + slug);
const hasNeeds = Object.values(participantNeeds).some((v) => v === true || (typeof v === 'string' && v.trim()));
const { data } = await api.post(`/meeting-planner/public/${slug}/vote`, {
voterName: voterName.trim(),
voterEmail: trimmedEmail || undefined,
voterToken: storedToken || undefined,
votes: Object.entries(votes).map(([optionId, value]) => ({ optionId, value })),
...(hasNeeds ? { participantNeeds } : {}),
});
// Store voter token for anonymous edit access
@ -520,6 +532,149 @@ export default function SchedulingPollPage() {
</Row>
))}
{/* Participant Needs (collapsible) */}
<Collapse
ghost
activeKey={needsExpanded ? ['needs'] : []}
onChange={(keys) => setNeedsExpanded(keys.includes('needs'))}
style={{ marginTop: 16 }}
items={[{
key: 'needs',
label: (
<Text style={{ fontSize: 13 }}>
Accessibility & Needs (optional)
</Text>
),
children: (
<div>
<Text type="secondary" style={{ display: 'block', marginBottom: 12, fontSize: 12 }}>
Help us prepare an accessible and inclusive space. This info is shared only with organizers by default.
</Text>
{/* Mobility / Accessibility */}
<Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>Accessibility</Text>
<Row gutter={[8, 4]} style={{ marginBottom: 12 }}>
<Col xs={24} sm={12}>
<Checkbox checked={participantNeeds.needsWheelchair} onChange={(e) => updateNeed('needsWheelchair', e.target.checked)}>
Wheelchair accessible
</Checkbox>
</Col>
<Col xs={24} sm={12}>
<Checkbox checked={participantNeeds.needsGroundFloor} onChange={(e) => updateNeed('needsGroundFloor', e.target.checked)}>
Ground floor / no stairs
</Checkbox>
</Col>
<Col xs={24} sm={12}>
<Checkbox checked={participantNeeds.needsHearingLoop} onChange={(e) => updateNeed('needsHearingLoop', e.target.checked)}>
Hearing loop
</Checkbox>
</Col>
<Col xs={24} sm={12}>
<Checkbox checked={participantNeeds.needsSignLanguage} onChange={(e) => updateNeed('needsSignLanguage', e.target.checked)}>
Sign language interpretation
</Checkbox>
</Col>
<Col xs={24}>
<Input
placeholder="Other accessibility needs..."
size="small"
value={participantNeeds.otherAccessibility || ''}
onChange={(e) => updateNeed('otherAccessibility', e.target.value || null)}
style={{ marginTop: 4 }}
/>
</Col>
</Row>
{/* Dietary */}
<Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>Dietary</Text>
<Row gutter={[8, 4]} style={{ marginBottom: 12 }}>
<Col xs={12} sm={8}><Checkbox checked={participantNeeds.isVegan} onChange={(e) => updateNeed('isVegan', e.target.checked)}>Vegan</Checkbox></Col>
<Col xs={12} sm={8}><Checkbox checked={participantNeeds.isVegetarian} onChange={(e) => updateNeed('isVegetarian', e.target.checked)}>Vegetarian</Checkbox></Col>
<Col xs={12} sm={8}><Checkbox checked={participantNeeds.isGlutenFree} onChange={(e) => updateNeed('isGlutenFree', e.target.checked)}>Gluten-free</Checkbox></Col>
<Col xs={12} sm={8}><Checkbox checked={participantNeeds.isHalal} onChange={(e) => updateNeed('isHalal', e.target.checked)}>Halal</Checkbox></Col>
<Col xs={12} sm={8}><Checkbox checked={participantNeeds.isKosher} onChange={(e) => updateNeed('isKosher', e.target.checked)}>Kosher</Checkbox></Col>
<Col xs={12} sm={8}><Checkbox checked={participantNeeds.hasNutAllergy} onChange={(e) => updateNeed('hasNutAllergy', e.target.checked)}>Nut allergy</Checkbox></Col>
<Col xs={24}>
<Input
placeholder="Other dietary needs..."
size="small"
value={participantNeeds.otherDietary || ''}
onChange={(e) => updateNeed('otherDietary', e.target.value || null)}
style={{ marginTop: 4 }}
/>
</Col>
</Row>
{/* Care Needs */}
<Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>Care &amp; Transportation</Text>
<Row gutter={[8, 4]} style={{ marginBottom: 12 }}>
<Col xs={24} sm={12}>
<Checkbox checked={participantNeeds.needsChildcare} onChange={(e) => updateNeed('needsChildcare', e.target.checked)}>
I need childcare
</Checkbox>
{participantNeeds.needsChildcare && (
<Input
placeholder="Ages, number of children..."
size="small"
value={participantNeeds.childcareDetails || ''}
onChange={(e) => updateNeed('childcareDetails', e.target.value || null)}
style={{ marginTop: 4 }}
/>
)}
</Col>
<Col xs={24} sm={12}>
<Checkbox checked={participantNeeds.needsTransportation} onChange={(e) => updateNeed('needsTransportation', e.target.checked)}>
I need transportation
</Checkbox>
{participantNeeds.needsTransportation && (
<Input
placeholder="Where from, any notes..."
size="small"
value={participantNeeds.transportationNotes || ''}
onChange={(e) => updateNeed('transportationNotes', e.target.value || null)}
style={{ marginTop: 4 }}
/>
)}
</Col>
</Row>
{/* Language */}
<Text strong style={{ display: 'block', marginBottom: 6, fontSize: 13 }}>Language</Text>
<Row gutter={[8, 8]} style={{ marginBottom: 8 }}>
<Col xs={24} sm={12}>
<Checkbox checked={participantNeeds.needsTranslation} onChange={(e) => updateNeed('needsTranslation', e.target.checked)}>
I need translation / interpretation
</Checkbox>
{participantNeeds.needsTranslation && (
<Input
placeholder="Which language?"
size="small"
value={participantNeeds.translationLanguage || ''}
onChange={(e) => updateNeed('translationLanguage', e.target.value || null)}
style={{ marginTop: 4 }}
/>
)}
</Col>
<Col xs={24} sm={12}>
<Select
placeholder="Who can see this info?"
size="small"
value={participantNeeds.visibilityConsent || 'organizer_only'}
onChange={(v) => updateNeed('visibilityConsent', v)}
style={{ width: '100%', marginTop: 4 }}
options={[
{ value: 'organizer_only', label: 'Organizer only' },
{ value: 'shared_with_hosts', label: 'Shared with hosts' },
{ value: 'public', label: 'Visible to all participants' },
]}
/>
</Col>
</Row>
</div>
),
}]}
/>
<Button
type="primary"
icon={<SendOutlined />}

View File

@ -24,6 +24,7 @@ export interface User {
email: string;
name: string | null;
phone: string | null;
pronouns: string | null;
role: UserRole;
roles: UserRole[];
status: UserStatus;
@ -70,6 +71,7 @@ export interface CreateUserPayload {
password: string;
name?: string;
phone?: string;
pronouns?: string;
role?: UserRole;
roles?: UserRole[];
status?: UserStatus;
@ -82,6 +84,7 @@ export interface UpdateUserPayload {
password?: string;
name?: string;
phone?: string;
pronouns?: string;
role?: UserRole;
roles?: UserRole[];
status?: UserStatus;
@ -2883,6 +2886,7 @@ export interface SchedulingPollVote {
pollId: string;
optionId: string;
userId: string | null;
contactId: string | null;
voterName: string;
voterEmail: string | null;
voterToken: string | null;
@ -2912,7 +2916,16 @@ export interface SchedulingPoll {
finalizedOption: SchedulingPollOption | null;
convertedShiftId: string | null;
convertedGancioEventId: number | null;
convertedCalendarItemId: string | null;
votingDeadline: string | null;
autoFinalize: boolean;
autoFinalizeThreshold: number | null;
autoConvertToCalendar: boolean;
autoConvertToGancio: boolean;
autoConvertToShift: boolean;
tieBreaker: 'earliest' | 'organizer_choice';
autoEnrollVoters: boolean;
autoFinalizeJobId: string | null;
allowAnonymous: boolean;
isPrivate: boolean;
notifyOnVote: boolean;
@ -2942,6 +2955,127 @@ export interface PollDetailResponse extends SchedulingPoll {
}>;
}
// --- Meeting Agendas & Action Items ---
export type AgendaStatus = 'draft' | 'active' | 'completed';
export type ActionItemStatus = 'open' | 'in_progress' | 'done' | 'blocked';
export type ActionItemPriority = 'low' | 'normal' | 'high' | 'urgent';
export interface AgendaItem {
id: string;
title: string;
durationMinutes?: number;
presenterUserId?: string;
notes?: string;
order: number;
}
export interface MeetingAgenda {
id: string;
shiftId: string | null;
pollId: string | null;
title: string;
items: AgendaItem[];
status: AgendaStatus;
createdByUserId: string;
createdBy?: { id: string; name: string | null };
minutes?: MeetingMinutes | null;
actionItems?: ActionItem[];
createdAt: string;
updatedAt: string;
}
export interface MeetingMinutes {
id: string;
agendaId: string;
notes: string;
decisions: Array<{ id: string; text: string; passed: boolean }>;
attendees: Array<{ name: string; pronouns?: string; userId?: string }>;
approvedAt: string | null;
approvedByUserId: string | null;
createdByUserId: string;
createdAt: string;
updatedAt: string;
}
export interface ActionItem {
id: string;
agendaId: string | null;
title: string;
description: string | null;
assigneeUserId: string | null;
assignee?: { id: string; name: string | null; email: string } | null;
dueDate: string | null;
status: ActionItemStatus;
priority: ActionItemPriority;
completedAt: string | null;
createdByUserId: string;
createdBy?: { id: string; name: string | null };
agenda?: { id: string; title: string } | null;
createdAt: string;
updatedAt: string;
}
export interface AgendasListResponse {
agendas: MeetingAgenda[];
pagination: PaginationMeta;
}
export interface ActionItemsListResponse {
actionItems: ActionItem[];
pagination: PaginationMeta;
}
export const ACTION_ITEM_STATUS_COLORS: Record<ActionItemStatus, string> = {
open: 'blue',
in_progress: 'orange',
done: 'green',
blocked: 'red',
};
export const ACTION_ITEM_STATUS_LABELS: Record<ActionItemStatus, string> = {
open: 'Open',
in_progress: 'In Progress',
done: 'Done',
blocked: 'Blocked',
};
export const ACTION_ITEM_PRIORITY_COLORS: Record<ActionItemPriority, string> = {
low: 'default',
normal: 'blue',
high: 'orange',
urgent: 'red',
};
// --- Participant Needs ---
export interface ParticipantNeeds {
id: string;
userId: string | null;
contactId: string | null;
needsWheelchair: boolean;
needsGroundFloor: boolean;
needsHearingLoop: boolean;
needsSignLanguage: boolean;
otherAccessibility: string | null;
isVegan: boolean;
isVegetarian: boolean;
isGlutenFree: boolean;
isHalal: boolean;
isKosher: boolean;
hasNutAllergy: boolean;
otherDietary: string | null;
needsChildcare: boolean;
childcareDetails: string | null;
needsTransportation: boolean;
transportationNotes: string | null;
preferredLanguage: string | null;
needsTranslation: boolean;
translationLanguage: string | null;
visibilityConsent: string;
updatedAt: string;
}
// --- System Upgrade ---
export interface UpgradeChangelogEntry {

View File

@ -0,0 +1,12 @@
-- Add POLL to CalendarItemSource enum
ALTER TYPE "CalendarItemSource" ADD VALUE IF NOT EXISTS 'POLL';
-- AlterTable
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_finalize" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_finalize_threshold" INTEGER;
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_convert_to_calendar" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_convert_to_gancio" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_convert_to_shift" BOOLEAN NOT NULL DEFAULT false;
ALTER TABLE "scheduling_polls" ADD COLUMN "tie_breaker" TEXT NOT NULL DEFAULT 'earliest';
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_finalize_job_id" TEXT;
ALTER TABLE "scheduling_polls" ADD COLUMN "converted_calendar_item_id" TEXT;

View File

@ -0,0 +1,160 @@
-- AlterEnum
ALTER TYPE "ContactActivityType" ADD VALUE 'POLL_VOTED';
-- AlterEnum
ALTER TYPE "ContactSource" ADD VALUE 'POLL_VOTE';
-- AlterEnum
ALTER TYPE "SignupSource" ADD VALUE 'POLL_CONVERSION';
-- AlterTable
ALTER TABLE "contacts" ADD COLUMN "pronouns" TEXT;
-- AlterTable
ALTER TABLE "scheduling_poll_votes" ADD COLUMN "contact_id" TEXT;
-- AlterTable
ALTER TABLE "scheduling_polls" ADD COLUMN "auto_enroll_voters" BOOLEAN NOT NULL DEFAULT true;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "pronouns" TEXT;
-- CreateTable
CREATE TABLE "participant_needs" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"contact_id" TEXT,
"needs_wheelchair" BOOLEAN NOT NULL DEFAULT false,
"needs_ground_floor" BOOLEAN NOT NULL DEFAULT false,
"needs_hearing_loop" BOOLEAN NOT NULL DEFAULT false,
"needs_sign_language" BOOLEAN NOT NULL DEFAULT false,
"other_accessibility" TEXT,
"is_vegan" BOOLEAN NOT NULL DEFAULT false,
"is_vegetarian" BOOLEAN NOT NULL DEFAULT false,
"is_gluten_free" BOOLEAN NOT NULL DEFAULT false,
"is_halal" BOOLEAN NOT NULL DEFAULT false,
"is_kosher" BOOLEAN NOT NULL DEFAULT false,
"has_nut_allergy" BOOLEAN NOT NULL DEFAULT false,
"other_dietary" TEXT,
"needs_childcare" BOOLEAN NOT NULL DEFAULT false,
"childcare_details" TEXT,
"needs_transportation" BOOLEAN NOT NULL DEFAULT false,
"transportation_notes" TEXT,
"preferred_language" TEXT DEFAULT 'en',
"needs_translation" BOOLEAN NOT NULL DEFAULT false,
"translation_language" TEXT,
"visibility_consent" TEXT NOT NULL DEFAULT 'organizer_only',
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "participant_needs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "meeting_agendas" (
"id" TEXT NOT NULL,
"shift_id" TEXT,
"poll_id" TEXT,
"title" TEXT NOT NULL,
"items" JSONB NOT NULL DEFAULT '[]',
"status" TEXT NOT NULL DEFAULT 'draft',
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "meeting_agendas_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "meeting_minutes" (
"id" TEXT NOT NULL,
"agenda_id" TEXT NOT NULL,
"notes" TEXT NOT NULL,
"decisions" JSONB NOT NULL DEFAULT '[]',
"attendees" JSONB NOT NULL DEFAULT '[]',
"approved_at" TIMESTAMP(3),
"approved_by_user_id" TEXT,
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "meeting_minutes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "action_items" (
"id" TEXT NOT NULL,
"agenda_id" TEXT,
"title" TEXT NOT NULL,
"description" TEXT,
"assignee_user_id" TEXT,
"due_date" TIMESTAMP(3),
"status" TEXT NOT NULL DEFAULT 'open',
"priority" TEXT NOT NULL DEFAULT 'normal',
"completed_at" TIMESTAMP(3),
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "action_items_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "participant_needs_user_id_key" ON "participant_needs"("user_id");
-- CreateIndex
CREATE UNIQUE INDEX "participant_needs_contact_id_key" ON "participant_needs"("contact_id");
-- CreateIndex
CREATE UNIQUE INDEX "meeting_agendas_shift_id_key" ON "meeting_agendas"("shift_id");
-- CreateIndex
CREATE UNIQUE INDEX "meeting_agendas_poll_id_key" ON "meeting_agendas"("poll_id");
-- CreateIndex
CREATE UNIQUE INDEX "meeting_minutes_agenda_id_key" ON "meeting_minutes"("agenda_id");
-- CreateIndex
CREATE INDEX "action_items_assignee_user_id_status_idx" ON "action_items"("assignee_user_id", "status");
-- CreateIndex
CREATE INDEX "action_items_due_date_idx" ON "action_items"("due_date");
-- CreateIndex
CREATE INDEX "scheduling_poll_votes_contact_id_idx" ON "scheduling_poll_votes"("contact_id");
-- AddForeignKey
ALTER TABLE "scheduling_poll_votes" ADD CONSTRAINT "scheduling_poll_votes_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "participant_needs" ADD CONSTRAINT "participant_needs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "participant_needs" ADD CONSTRAINT "participant_needs_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meeting_agendas" ADD CONSTRAINT "meeting_agendas_shift_id_fkey" FOREIGN KEY ("shift_id") REFERENCES "shifts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meeting_agendas" ADD CONSTRAINT "meeting_agendas_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "scheduling_polls"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meeting_agendas" ADD CONSTRAINT "meeting_agendas_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meeting_minutes" ADD CONSTRAINT "meeting_minutes_agenda_id_fkey" FOREIGN KEY ("agenda_id") REFERENCES "meeting_agendas"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meeting_minutes" ADD CONSTRAINT "meeting_minutes_approved_by_user_id_fkey" FOREIGN KEY ("approved_by_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "meeting_minutes" ADD CONSTRAINT "meeting_minutes_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "action_items" ADD CONSTRAINT "action_items_agenda_id_fkey" FOREIGN KEY ("agenda_id") REFERENCES "meeting_agendas"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "action_items" ADD CONSTRAINT "action_items_assignee_user_id_fkey" FOREIGN KEY ("assignee_user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "action_items" ADD CONSTRAINT "action_items_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -48,6 +48,7 @@ model User {
password String // bcrypt hashed
name String?
phone String?
pronouns String?
role UserRole @default(USER)
roles Json @default("[]") // Array of UserRole strings for multi-role support
status UserStatus @default(ACTIVE)
@ -166,6 +167,16 @@ model User {
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
// Participant needs
participantNeeds ParticipantNeeds? @relation("UserParticipantNeeds")
// Meeting agendas & action items
agendasCreated MeetingAgenda[] @relation("AgendaCreator")
minutesCreated MeetingMinutes[] @relation("MinutesCreator")
minutesApproved MeetingMinutes[] @relation("MinutesApprover")
actionItemsAssigned ActionItem[] @relation("ActionItemAssignee")
actionItemsCreated ActionItem[] @relation("ActionItemCreator")
// Referral system
inviteCodesCreated InviteCode[] @relation("InviteCodesCreated")
referralsMade Referral[] @relation("ReferralsMade")
@ -736,6 +747,9 @@ model Shift {
// Scheduling poll conversion
convertedFromPoll SchedulingPoll? @relation("PollConvertedShift")
// Meeting agenda
agenda MeetingAgenda? @relation("ShiftAgenda")
@@index([cutId])
@@index([seriesId])
@@map("shifts")
@ -750,6 +764,7 @@ enum SignupSource {
AUTHENTICATED
PUBLIC
ADMIN
POLL_CONVERSION
}
model ShiftSignup {
@ -4168,6 +4183,7 @@ enum ContactSource {
SHIFT_SIGNUP
SMS_CONTACT
DONATION
POLL_VOTE
MANUAL
}
@ -4194,6 +4210,7 @@ enum ContactActivityType {
PROFILE_SELF_EDIT
PROFILE_PHOTO_UPDATED
USER_LOGIN
POLL_VOTED
}
model Contact {
@ -4203,6 +4220,7 @@ model Contact {
lastName String?
email String?
phone String?
pronouns String?
// CRM data
tags Json @default("[]") // String array
@ -4247,6 +4265,8 @@ model Contact {
connectionsTo ContactConnection[] @relation("ConnectionTo")
activities ContactActivity[]
smsConversations SmsConversation[] @relation("ContactSmsConversations")
pollVotes SchedulingPollVote[] @relation("PollVoteContact")
participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds")
@@index([email])
@@index([phone])
@ -4404,7 +4424,16 @@ model SchedulingPoll {
convertedShiftId String? @unique @map("converted_shift_id")
convertedShift Shift? @relation("PollConvertedShift", fields: [convertedShiftId], references: [id], onDelete: SetNull)
convertedGancioEventId Int? @map("converted_gancio_event_id")
convertedCalendarItemId String? @map("converted_calendar_item_id")
votingDeadline DateTime? @map("voting_deadline")
autoFinalize Boolean @default(false) @map("auto_finalize")
autoFinalizeThreshold Int? @map("auto_finalize_threshold")
autoConvertToCalendar Boolean @default(false) @map("auto_convert_to_calendar")
autoConvertToGancio Boolean @default(false) @map("auto_convert_to_gancio")
autoConvertToShift Boolean @default(false) @map("auto_convert_to_shift")
tieBreaker String @default("earliest") @map("tie_breaker")
autoEnrollVoters Boolean @default(true) @map("auto_enroll_voters")
autoFinalizeJobId String? @map("auto_finalize_job_id")
allowAnonymous Boolean @default(true) @map("allow_anonymous")
isPrivate Boolean @default(false) @map("is_private")
notifyOnVote Boolean @default(true) @map("notify_on_vote")
@ -4416,6 +4445,7 @@ model SchedulingPoll {
options SchedulingPollOption[] @relation("PollOptions")
votes SchedulingPollVote[] @relation("PollVotes")
comments SchedulingPollComment[] @relation("PollComments")
agenda MeetingAgenda? @relation("PollAgenda")
@@index([createdByUserId])
@@index([status])
@ -4451,6 +4481,8 @@ model SchedulingPollVote {
voterName String @map("voter_name")
voterEmail String? @map("voter_email")
voterToken String? @map("voter_token") // anonymous edit access (cuid)
contactId String? @map("contact_id")
contact Contact? @relation("PollVoteContact", fields: [contactId], references: [id], onDelete: SetNull)
value PollVoteValue
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@ -4458,6 +4490,7 @@ model SchedulingPollVote {
@@unique([optionId, userId])
@@unique([optionId, voterToken])
@@index([pollId])
@@index([contactId])
@@map("scheduling_poll_votes")
}
@ -4906,6 +4939,7 @@ enum CalendarShowDetailsTo {
enum CalendarItemSource {
MANUAL
ICS_FEED
POLL
}
enum CalendarRecurrenceFrequency {
@ -5131,3 +5165,119 @@ model DocCollabState {
@@map("doc_collab_state")
}
// ============================================================================
// PARTICIPANT NEEDS
// ============================================================================
model ParticipantNeeds {
id String @id @default(cuid())
userId String? @unique @map("user_id")
user User? @relation("UserParticipantNeeds", fields: [userId], references: [id], onDelete: SetNull)
contactId String? @unique @map("contact_id")
contact Contact? @relation("ContactParticipantNeeds", fields: [contactId], references: [id], onDelete: SetNull)
// Accessibility
needsWheelchair Boolean @default(false) @map("needs_wheelchair")
needsGroundFloor Boolean @default(false) @map("needs_ground_floor")
needsHearingLoop Boolean @default(false) @map("needs_hearing_loop")
needsSignLanguage Boolean @default(false) @map("needs_sign_language")
otherAccessibility String? @db.Text @map("other_accessibility")
// Dietary
isVegan Boolean @default(false) @map("is_vegan")
isVegetarian Boolean @default(false) @map("is_vegetarian")
isGlutenFree Boolean @default(false) @map("is_gluten_free")
isHalal Boolean @default(false) @map("is_halal")
isKosher Boolean @default(false) @map("is_kosher")
hasNutAllergy Boolean @default(false) @map("has_nut_allergy")
otherDietary String? @db.Text @map("other_dietary")
// Care barriers
needsChildcare Boolean @default(false) @map("needs_childcare")
childcareDetails String? @db.Text @map("childcare_details")
needsTransportation Boolean @default(false) @map("needs_transportation")
transportationNotes String? @db.Text @map("transportation_notes")
// Communication
preferredLanguage String? @default("en") @map("preferred_language")
needsTranslation Boolean @default(false) @map("needs_translation")
translationLanguage String? @map("translation_language")
// Consent
visibilityConsent String @default("organizer_only") @map("visibility_consent")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("participant_needs")
}
// ============================================================================
// MEETING AGENDAS & ACTION ITEMS
// ============================================================================
model MeetingAgenda {
id String @id @default(cuid())
shiftId String? @unique @map("shift_id")
shift Shift? @relation("ShiftAgenda", fields: [shiftId], references: [id], onDelete: SetNull)
pollId String? @unique @map("poll_id")
poll SchedulingPoll? @relation("PollAgenda", fields: [pollId], references: [id], onDelete: SetNull)
title String
items Json @default("[]") // Array<{ id, title, durationMinutes, presenterUserId, notes, order }>
status String @default("draft") // "draft" | "active" | "completed"
minutes MeetingMinutes?
actionItems ActionItem[]
createdByUserId String @map("created_by_user_id")
createdBy User @relation("AgendaCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("meeting_agendas")
}
model MeetingMinutes {
id String @id @default(cuid())
agendaId String @unique @map("agenda_id")
agenda MeetingAgenda @relation(fields: [agendaId], references: [id], onDelete: Cascade)
notes String @db.Text
decisions Json @default("[]") // Array<{ id, text, passed: boolean }>
attendees Json @default("[]") // Array<{ name, pronouns, userId? }>
approvedAt DateTime? @map("approved_at")
approvedByUserId String? @map("approved_by_user_id")
approvedBy User? @relation("MinutesApprover", fields: [approvedByUserId], references: [id], onDelete: SetNull)
createdByUserId String @map("created_by_user_id")
createdBy User @relation("MinutesCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("meeting_minutes")
}
model ActionItem {
id String @id @default(cuid())
agendaId String? @map("agenda_id")
agenda MeetingAgenda? @relation(fields: [agendaId], references: [id], onDelete: SetNull)
title String
description String? @db.Text
assigneeUserId String? @map("assignee_user_id")
assignee User? @relation("ActionItemAssignee", fields: [assigneeUserId], references: [id], onDelete: SetNull)
dueDate DateTime? @map("due_date")
status String @default("open") // "open" | "in_progress" | "done" | "blocked"
priority String @default("normal") // "low" | "normal" | "high" | "urgent"
completedAt DateTime? @map("completed_at")
createdByUserId String @map("created_by_user_id")
createdBy User @relation("ActionItemCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@index([assigneeUserId, status])
@@index([dueDate])
@@map("action_items")
}

View File

@ -10,6 +10,13 @@ export const createPollSchema = z.object({
isPrivate: z.boolean().optional().default(false),
notifyOnVote: z.boolean().optional().default(true),
votingDeadline: z.string().datetime().optional(),
autoFinalize: z.boolean().optional().default(false),
autoFinalizeThreshold: z.number().int().min(1).max(100).nullable().optional(),
autoConvertToCalendar: z.boolean().optional().default(false),
autoConvertToGancio: z.boolean().optional().default(false),
autoConvertToShift: z.boolean().optional().default(false),
tieBreaker: z.enum(['earliest', 'organizer_choice']).optional().default('earliest'),
autoEnrollVoters: z.boolean().optional().default(true),
options: z.array(z.object({
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be YYYY-MM-DD'),
startTime: z.string().regex(/^\d{2}:\d{2}$/, 'Start time must be HH:MM'),
@ -27,6 +34,13 @@ export const updatePollSchema = z.object({
notifyOnVote: z.boolean().optional(),
votingDeadline: z.string().datetime().nullable().optional(),
status: z.nativeEnum(SchedulingPollStatus).optional(),
autoFinalize: z.boolean().optional(),
autoFinalizeThreshold: z.number().int().min(1).max(100).nullable().optional(),
autoConvertToCalendar: z.boolean().optional(),
autoConvertToGancio: z.boolean().optional(),
autoConvertToShift: z.boolean().optional(),
tieBreaker: z.enum(['earliest', 'organizer_choice']).optional(),
autoEnrollVoters: z.boolean().optional(),
});
export const addOptionsSchema = z.object({
@ -37,6 +51,29 @@ export const addOptionsSchema = z.object({
})).min(1).max(20),
});
export const participantNeedsInlineSchema = z.object({
needsWheelchair: z.boolean().optional(),
needsGroundFloor: z.boolean().optional(),
needsHearingLoop: z.boolean().optional(),
needsSignLanguage: z.boolean().optional(),
otherAccessibility: z.string().max(1000).nullable().optional(),
isVegan: z.boolean().optional(),
isVegetarian: z.boolean().optional(),
isGlutenFree: z.boolean().optional(),
isHalal: z.boolean().optional(),
isKosher: z.boolean().optional(),
hasNutAllergy: z.boolean().optional(),
otherDietary: z.string().max(1000).nullable().optional(),
needsChildcare: z.boolean().optional(),
childcareDetails: z.string().max(1000).nullable().optional(),
needsTransportation: z.boolean().optional(),
transportationNotes: z.string().max(1000).nullable().optional(),
preferredLanguage: z.string().max(10).nullable().optional(),
needsTranslation: z.boolean().optional(),
translationLanguage: z.string().max(100).nullable().optional(),
visibilityConsent: z.enum(['organizer_only', 'shared_with_hosts', 'public']).optional().default('organizer_only'),
}).optional();
export const submitVotesSchema = z.object({
voterName: z.string().min(1, 'Name is required').max(100),
voterEmail: z.preprocess(
@ -48,6 +85,7 @@ export const submitVotesSchema = z.object({
optionId: z.string().min(1),
value: z.nativeEnum(PollVoteValue),
})).min(1, 'At least one vote required'),
participantNeeds: participantNeedsInlineSchema,
});
export const submitCommentSchema = z.object({

View File

@ -5,6 +5,8 @@ import { AppError } from '../../middleware/error-handler';
import { emailService } from '../../services/email.service';
import { generateSlug } from '../../utils/slug';
import { logger } from '../../utils/logger';
import { pollAutoFinalizeQueueService } from '../../services/poll-auto-finalize-queue.service';
import { participantNeedsService } from '../people/participant-needs.service';
import type {
CreatePollInput,
UpdatePollInput,
@ -61,7 +63,7 @@ const pollDetailPublicInclude = {
_count: { select: { options: true, votes: true, comments: true } },
} as const;
function aggregateVotes(options: Array<{ id: string; votes: Array<{ value: PollVoteValue }> }>) {
function aggregateVotes<T extends { id: string; votes: Array<{ value: PollVoteValue }> }>(options: T[]) {
return options.map((opt) => {
let yesCount = 0;
let ifNeedBeCount = 0;
@ -242,6 +244,13 @@ export const meetingPlannerService = {
isPrivate: data.isPrivate,
notifyOnVote: data.notifyOnVote,
votingDeadline: data.votingDeadline ? new Date(data.votingDeadline) : null,
autoFinalize: data.autoFinalize,
autoFinalizeThreshold: data.autoFinalizeThreshold,
autoConvertToCalendar: data.autoConvertToCalendar,
autoConvertToGancio: data.autoConvertToGancio,
autoConvertToShift: data.autoConvertToShift,
tieBreaker: data.tieBreaker,
autoEnrollVoters: data.autoEnrollVoters,
createdByUserId: userId,
options: {
create: data.options.map((opt, i) => ({
@ -255,6 +264,17 @@ export const meetingPlannerService = {
include: pollInclude,
});
// Schedule auto-finalize job if enabled with a deadline
if (poll.autoFinalize && poll.votingDeadline) {
const jobId = await pollAutoFinalizeQueueService.scheduleJob(poll.id, poll.votingDeadline);
if (jobId) {
await prisma.schedulingPoll.update({
where: { id: poll.id },
data: { autoFinalizeJobId: jobId },
});
}
}
return poll;
},
@ -274,12 +294,41 @@ export const meetingPlannerService = {
updateData.votingDeadline = data.votingDeadline ? new Date(data.votingDeadline) : null;
}
if (data.status !== undefined) updateData.status = data.status;
if (data.autoFinalize !== undefined) updateData.autoFinalize = data.autoFinalize;
if (data.autoFinalizeThreshold !== undefined) updateData.autoFinalizeThreshold = data.autoFinalizeThreshold;
if (data.autoConvertToCalendar !== undefined) updateData.autoConvertToCalendar = data.autoConvertToCalendar;
if (data.autoConvertToGancio !== undefined) updateData.autoConvertToGancio = data.autoConvertToGancio;
if (data.autoConvertToShift !== undefined) updateData.autoConvertToShift = data.autoConvertToShift;
if (data.tieBreaker !== undefined) updateData.tieBreaker = data.tieBreaker;
if (data.autoEnrollVoters !== undefined) updateData.autoEnrollVoters = data.autoEnrollVoters;
return prisma.schedulingPoll.update({
// Cancel existing job if status changed to CANCELLED or autoFinalize disabled
if (data.status === 'CANCELLED' || data.autoFinalize === false) {
await pollAutoFinalizeQueueService.cancelJob(id);
updateData.autoFinalizeJobId = null;
}
const updated = await prisma.schedulingPoll.update({
where: { id },
data: updateData,
include: pollInclude,
});
// Re-schedule auto-finalize job if deadline or autoFinalize changed
const deadlineChanged = data.votingDeadline !== undefined;
const autoFinalizeChanged = data.autoFinalize !== undefined;
if ((deadlineChanged || autoFinalizeChanged) && updated.autoFinalize && updated.votingDeadline && updated.status === 'OPEN') {
await pollAutoFinalizeQueueService.cancelJob(id);
const jobId = await pollAutoFinalizeQueueService.scheduleJob(id, updated.votingDeadline);
if (jobId) {
await prisma.schedulingPoll.update({
where: { id },
data: { autoFinalizeJobId: jobId },
});
}
}
return updated;
},
async delete(id: string) {
@ -413,6 +462,18 @@ export const meetingPlannerService = {
})
);
// Link voter to Contact CRM + upsert participant needs (fire-and-forget)
if (data.voterEmail) {
this.linkVoterToContact(poll.id, poll.title, data.voterName, data.voterEmail, data.votes.map(v => v.optionId), userId ?? undefined, voterToken ?? undefined, data.participantNeeds).catch((err) =>
logger.error('Failed to link voter to contact', { error: err })
);
} else if (userId && data.participantNeeds) {
// Authenticated voter without email — upsert needs by userId
participantNeedsService.upsert(data.participantNeeds, userId).catch((err) =>
logger.error('Failed to upsert participant needs', { error: err })
);
}
// Notify organizer
if (poll.notifyOnVote) {
this.notifyOrganizer(poll.createdBy.email, poll.title, data.voterName).catch((err) =>
@ -420,6 +481,23 @@ export const meetingPlannerService = {
);
}
// Check auto-finalize threshold
if (poll.autoFinalize && poll.autoFinalizeThreshold && poll.status === 'OPEN') {
const optionsWithVotes = await prisma.schedulingPollOption.findMany({
where: { pollId: poll.id },
include: { votes: { where: { value: 'YES' } } },
});
for (const opt of optionsWithVotes) {
if (opt.votes.length >= poll.autoFinalizeThreshold) {
// Fire-and-forget auto-finalization
this.autoFinalizeAndConvert(poll.id, opt.id).catch((err) =>
logger.error('Auto-finalize by threshold failed', { error: err, pollId: poll.id })
);
break;
}
}
}
return { voterToken };
},
@ -481,11 +559,15 @@ export const meetingPlannerService = {
const option = poll.options.find((o) => o.id === data.optionId);
if (!option) throw new AppError(400, 'Option not found in this poll');
// Cancel any pending auto-finalize job
await pollAutoFinalizeQueueService.cancelJob(id);
const updated = await prisma.schedulingPoll.update({
where: { id },
data: {
status: 'FINALIZED',
finalizedOptionId: data.optionId,
autoFinalizeJobId: null,
},
include: pollDetailInclude,
});
@ -569,6 +651,455 @@ export const meetingPlannerService = {
return { gancioEventId: eventId };
},
/**
* Called by BullMQ worker when deadline fires. Picks the winning option and auto-finalizes.
*/
async processAutoFinalize(pollId: string) {
const poll = await prisma.schedulingPoll.findUnique({
where: { id: pollId },
include: {
options: {
orderBy: { date: 'asc' },
include: { votes: true },
},
createdBy: { select: { email: true, name: true } },
},
});
if (!poll || poll.status !== 'OPEN') {
logger.info(`Poll ${pollId} skipped auto-finalize (status: ${poll?.status ?? 'not found'})`);
return;
}
const scored = aggregateVotes(poll.options);
const maxScore = Math.max(...scored.map((o) => o.score));
// No viable option
if (maxScore === 0) {
await prisma.schedulingPoll.update({
where: { id: pollId },
data: { status: 'CLOSED', autoFinalizeJobId: null },
});
logger.info(`Poll ${pollId} closed with no viable votes`);
// Notify organizer
await emailService.sendEmail({
to: poll.createdBy.email,
subject: `Poll expired: "${poll.title}"`,
html: `<p>Your scheduling poll "<strong>${escapeHtml(poll.title)}</strong>" has expired with no viable date options.</p>`,
text: `Your scheduling poll "${poll.title}" has expired with no viable date options.`,
}).catch((err) => logger.error('Failed to send poll expiry email', { error: err }));
return;
}
// Find winners (could be tied)
const winners = scored.filter((o) => o.score === maxScore);
if (winners.length === 1) {
await this.autoFinalizeAndConvert(pollId, winners[0].id);
return;
}
// Tie-breaking
if (poll.tieBreaker === 'earliest') {
// Pick the earliest date+time
const earliest = winners.sort((a, b) => {
const dateA = new Date(a.date).getTime();
const dateB = new Date(b.date).getTime();
if (dateA !== dateB) return dateA - dateB;
return a.startTime.localeCompare(b.startTime);
})[0];
await this.autoFinalizeAndConvert(pollId, earliest.id);
} else {
// organizer_choice — close and notify
await prisma.schedulingPoll.update({
where: { id: pollId },
data: { status: 'CLOSED', autoFinalizeJobId: null },
});
await emailService.sendEmail({
to: poll.createdBy.email,
subject: `Tie in poll: "${poll.title}" — choose a winner`,
html: `<p>Your scheduling poll "<strong>${escapeHtml(poll.title)}</strong>" has a tie between ${winners.length} options. Please finalize manually.</p>`,
text: `Your scheduling poll "${poll.title}" has a tie between ${winners.length} options. Please finalize manually.`,
}).catch((err) => logger.error('Failed to send tie notification', { error: err }));
}
},
/**
* Shared auto-finalize logic used by both deadline and threshold triggers.
* Uses status guard to prevent double-finalize from concurrent invocations.
*/
async autoFinalizeAndConvert(pollId: string, winningOptionId: string) {
// Atomic guard: only finalize if still OPEN
const updated = await prisma.schedulingPoll.updateMany({
where: { id: pollId, status: 'OPEN' },
data: {
status: 'FINALIZED',
finalizedOptionId: winningOptionId,
autoFinalizeJobId: null,
},
});
if (updated.count === 0) {
logger.info(`Poll ${pollId} already finalized (concurrent guard)`);
return;
}
// Cancel any pending deadline job
await pollAutoFinalizeQueueService.cancelJob(pollId);
// Re-fetch with full details for notifications and conversions
const poll = await prisma.schedulingPoll.findUnique({
where: { id: pollId },
include: {
...pollDetailInclude,
},
});
if (!poll) return;
logger.info(`Auto-finalized poll ${pollId} with option ${winningOptionId}`);
// Notify voters
this.notifyVotersFinalized(poll).catch((err) =>
logger.error('Failed to send auto-finalization notifications', { error: err })
);
const finalOption = poll.options.find((o: any) => o.id === winningOptionId);
if (!finalOption) return;
// Auto-convert to CalendarItem
if (poll.autoConvertToCalendar) {
try {
const { calendarService } = await import('../calendar/calendar.service');
const { CalendarSystemType } = await import('@prisma/client');
await calendarService.ensureSystemLayers(poll.createdByUserId);
const layers = await calendarService.getUserLayers(poll.createdByUserId);
const pollsLayer = layers.find((l: any) => l.systemType === CalendarSystemType.POLLS);
if (pollsLayer) {
const item = await prisma.calendarItem.create({
data: {
userId: poll.createdByUserId,
layerId: pollsLayer.id,
title: poll.title,
description: poll.description,
location: poll.location,
date: finalOption.date,
startTime: finalOption.startTime,
endTime: finalOption.endTime,
sourceType: 'POLL',
sourceId: poll.id,
},
});
await prisma.schedulingPoll.update({
where: { id: pollId },
data: { convertedCalendarItemId: item.id },
});
logger.info(`Created calendar item ${item.id} from poll ${pollId}`);
}
} catch (err) {
logger.error('Auto-convert to calendar failed', { error: err, pollId });
}
}
// Auto-convert to Gancio event
if (poll.autoConvertToGancio) {
try {
const { gancioClient } = await import('../../services/gancio.client');
const eventId = await gancioClient.createEvent({
title: poll.title,
description: poll.description,
location: poll.location,
date: finalOption.date,
startTime: finalOption.startTime,
endTime: finalOption.endTime,
});
if (eventId) {
await prisma.schedulingPoll.update({
where: { id: pollId },
data: { convertedGancioEventId: eventId },
});
logger.info(`Created Gancio event ${eventId} from poll ${pollId}`);
}
} catch (err) {
logger.error('Auto-convert to Gancio failed', { error: err, pollId });
}
}
// Auto-convert to Shift
let createdShiftId: string | null = null;
if (poll.autoConvertToShift) {
try {
const shift = await prisma.shift.create({
data: {
title: poll.title,
description: poll.description,
date: finalOption.date,
startTime: finalOption.startTime,
endTime: finalOption.endTime,
location: poll.location,
maxVolunteers: 10,
isPublic: true,
},
});
createdShiftId = shift.id;
await prisma.schedulingPoll.update({
where: { id: pollId },
data: { convertedShiftId: shift.id },
});
logger.info(`Created shift ${shift.id} from poll ${pollId}`);
// Auto-enroll YES voters into the shift
this.autoEnrollVotersIntoShift(poll, shift.id, winningOptionId).catch((err) =>
logger.error('Auto-enroll voters failed', { error: err, pollId })
);
} catch (err) {
logger.error('Auto-convert to shift failed', { error: err, pollId });
}
}
// Auto-create meeting agenda
try {
await prisma.meetingAgenda.create({
data: {
pollId,
shiftId: createdShiftId,
title: poll.title,
createdByUserId: poll.createdByUserId,
items: [
{ id: '1', title: 'Check-in / introductions', durationMinutes: 10, order: 0 },
{ id: '2', title: 'Report-backs on action items', durationMinutes: 10, order: 1 },
{ id: '3', title: `Main discussion: ${poll.title}`, durationMinutes: 30, order: 2 },
{ id: '4', title: 'Action items', durationMinutes: 10, order: 3 },
{ id: '5', title: 'Next steps', durationMinutes: 5, order: 4 },
] as unknown as Prisma.InputJsonValue,
},
});
logger.info(`Created meeting agenda from poll ${pollId}`);
} catch (err) {
logger.error('Auto-create agenda failed', { error: err, pollId });
}
// Aggregate participant needs for organizer notification
this.sendNeedsSummaryToOrganizer(poll).catch((err) =>
logger.error('Failed to send needs summary', { error: err, pollId })
);
},
/**
* Find-or-create a Contact for a poll voter and log the vote as an activity.
*/
async linkVoterToContact(
pollId: string,
pollTitle: string,
voterName: string,
voterEmail: string,
optionIds: string[],
userId?: string,
voterToken?: string,
participantNeeds?: Record<string, any>,
) {
const normalizedEmail = voterEmail.trim().toLowerCase();
// Find existing contact by email
let contact = await prisma.contact.findFirst({
where: {
OR: [
{ email: normalizedEmail },
{ emails: { some: { email: normalizedEmail } } },
],
},
});
if (!contact) {
// Create new contact
const nameParts = voterName.trim().split(/\s+/);
contact = await prisma.contact.create({
data: {
displayName: voterName,
firstName: nameParts[0] || null,
lastName: nameParts.length > 1 ? nameParts.slice(1).join(' ') : null,
email: normalizedEmail,
primarySource: 'POLL_VOTE',
userId: userId || null,
},
});
// Also create ContactEmail entry
await prisma.contactEmail.create({
data: {
contactId: contact.id,
email: normalizedEmail,
label: 'Primary',
isPrimary: true,
},
}).catch(() => {}); // ignore if duplicate
}
// Log activity
await prisma.contactActivity.create({
data: {
contactId: contact.id,
type: 'POLL_VOTED',
title: `Voted on: ${pollTitle}`,
metadata: { pollId, optionIds } as unknown as Prisma.InputJsonValue,
},
});
// Update vote records with contactId
const whereClause = userId
? { pollId, userId }
: voterToken
? { pollId, voterToken }
: { pollId, voterName, userId: null, voterToken: null };
await prisma.schedulingPollVote.updateMany({
where: whereClause,
data: { contactId: contact.id },
});
// Upsert participant needs if provided
if (participantNeeds && Object.keys(participantNeeds).length > 0) {
await participantNeedsService.upsert(
participantNeeds,
userId,
contact.id,
);
}
},
/**
* Auto-enroll YES voters into a shift created from poll finalization.
*/
async autoEnrollVotersIntoShift(poll: any, shiftId: string, winningOptionId: string) {
const yesVotes = await prisma.schedulingPollVote.findMany({
where: {
pollId: poll.id,
optionId: winningOptionId,
value: 'YES',
voterEmail: { not: null },
},
});
if (yesVotes.length === 0) return;
const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
if (!shift) return;
for (const vote of yesVotes) {
if (!vote.voterEmail) continue;
try {
// Check if already signed up
const existing = await prisma.shiftSignup.findUnique({
where: { shiftId_userEmail: { shiftId, userEmail: vote.voterEmail } },
});
if (existing) continue;
await prisma.shiftSignup.create({
data: {
shiftId,
shiftTitle: shift.title,
userId: vote.userId,
userEmail: vote.voterEmail,
userName: vote.voterName,
signupSource: 'POLL_CONVERSION',
},
});
// Increment volunteer count
await prisma.shift.update({
where: { id: shiftId },
data: { currentVolunteers: { increment: 1 } },
});
if (poll.autoEnrollVoters) {
// Send enrollment notification
const dateStr = new Date(shift.date).toLocaleDateString('en-CA', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
});
await emailService.sendEmail({
to: vote.voterEmail,
subject: `You're signed up for "${shift.title}"`,
html: `<p>Based on your poll vote, you've been automatically signed up for "<strong>${escapeHtml(shift.title)}</strong>".</p>
<p><strong>${dateStr}</strong><br/>${shift.startTime} - ${shift.endTime}</p>
${shift.location ? `<p>Location: ${escapeHtml(shift.location)}</p>` : ''}`,
text: `Based on your poll vote, you've been automatically signed up for "${shift.title}".\n${dateStr}\n${shift.startTime} - ${shift.endTime}${shift.location ? `\nLocation: ${shift.location}` : ''}`,
}).catch((err) => logger.error('Failed to send auto-enroll email', { error: err }));
}
} catch (err) {
logger.error('Failed to auto-enroll voter', { error: err, voterEmail: vote.voterEmail });
}
}
},
/**
* Aggregate participant needs for YES voters and email the organizer a prep summary.
*/
async sendNeedsSummaryToOrganizer(poll: any) {
// Get all YES voter user/contact IDs on the winning option
const yesVotes = await prisma.schedulingPollVote.findMany({
where: { pollId: poll.id, optionId: poll.finalizedOptionId, value: 'YES' },
select: { userId: true, contactId: true },
});
const userIds = yesVotes.map(v => v.userId).filter(Boolean) as string[];
const contactIds = yesVotes.map(v => v.contactId).filter(Boolean) as string[];
if (userIds.length === 0 && contactIds.length === 0) return;
const needs = await prisma.participantNeeds.findMany({
where: {
OR: [
...(userIds.length ? [{ userId: { in: userIds } }] : []),
...(contactIds.length ? [{ contactId: { in: contactIds } }] : []),
],
},
});
if (needs.length === 0) return;
// Aggregate
const summary: string[] = [];
const wheelchair = needs.filter(n => n.needsWheelchair).length;
const groundFloor = needs.filter(n => n.needsGroundFloor).length;
const hearingLoop = needs.filter(n => n.needsHearingLoop).length;
const signLanguage = needs.filter(n => n.needsSignLanguage).length;
const childcare = needs.filter(n => n.needsChildcare).length;
const transportation = needs.filter(n => n.needsTransportation).length;
const vegan = needs.filter(n => n.isVegan).length;
const vegetarian = needs.filter(n => n.isVegetarian).length;
const glutenFree = needs.filter(n => n.isGlutenFree).length;
const halal = needs.filter(n => n.isHalal).length;
const kosher = needs.filter(n => n.isKosher).length;
const nutAllergy = needs.filter(n => n.hasNutAllergy).length;
const translation = needs.filter(n => n.needsTranslation).length;
if (wheelchair) summary.push(`${wheelchair} need wheelchair access`);
if (groundFloor) summary.push(`${groundFloor} need ground-floor access`);
if (hearingLoop) summary.push(`${hearingLoop} need hearing loop`);
if (signLanguage) summary.push(`${signLanguage} need sign language interpretation`);
if (childcare) summary.push(`${childcare} need childcare`);
if (transportation) summary.push(`${transportation} need transportation`);
if (vegan) summary.push(`${vegan} vegan`);
if (vegetarian) summary.push(`${vegetarian} vegetarian`);
if (glutenFree) summary.push(`${glutenFree} gluten-free`);
if (halal) summary.push(`${halal} halal`);
if (kosher) summary.push(`${kosher} kosher`);
if (nutAllergy) summary.push(`${nutAllergy} nut allergy`);
if (translation) summary.push(`${translation} need translation`);
if (summary.length === 0) return;
// Send to organizer
const organizerEmail = poll.createdBy?.email;
if (!organizerEmail) return;
await emailService.sendEmail({
to: organizerEmail,
subject: `Prep checklist for "${poll.title}"`,
html: `<p>Participant needs for "<strong>${escapeHtml(poll.title)}</strong>":</p>
<ul>${summary.map(s => `<li>${escapeHtml(s)}</li>`).join('')}</ul>`,
text: `Participant needs for "${poll.title}":\n${summary.map(s => `- ${s}`).join('\n')}`,
});
},
async notifyOrganizer(email: string, pollTitle: string, voterName: string) {
try {
await emailService.sendEmail({

View File

@ -0,0 +1,77 @@
import { Router, Request, Response, NextFunction } from 'express';
import { actionItemsService } from './action-items.service';
import {
createActionItemSchema,
updateActionItemSchema,
listActionItemsSchema,
} from './action-items.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { EVENTS_ROLES } from '../../utils/roles';
const router = Router();
// --- Routes requiring EVENTS_ROLES ---
// List all action items
router.get('/', authenticate, requireRole(...EVENTS_ROLES), validate(listActionItemsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await actionItemsService.findAll(req.query as any);
res.json(result);
} catch (err) { next(err); }
});
// Current user's action items (authenticate only)
router.get('/mine', authenticate, async (req: Request, res: Response, next: NextFunction) => {
try {
const status = req.query.status as string | undefined;
const items = await actionItemsService.findByUser(req.user!.id, status);
res.json(items);
} catch (err) { next(err); }
});
// Overdue items
router.get('/overdue', authenticate, requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const items = await actionItemsService.getOverdue();
res.json(items);
} catch (err) { next(err); }
});
// Get action item detail
router.get('/:id', authenticate, async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const item = await actionItemsService.findById(id);
res.json(item);
} catch (err) { next(err); }
});
// Create action item
router.post('/', authenticate, requireRole(...EVENTS_ROLES), validate(createActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const item = await actionItemsService.create(req.body, req.user!.id);
res.status(201).json(item);
} catch (err) { next(err); }
});
// Update action item (authenticate only - assignees can update their own)
router.put('/:id', authenticate, validate(updateActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const item = await actionItemsService.update(id, req.body);
res.json(item);
} catch (err) { next(err); }
});
// Delete action item
router.delete('/:id', authenticate, requireRole(...EVENTS_ROLES), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await actionItemsService.delete(id);
res.json({ message: 'Action item deleted' });
} catch (err) { next(err); }
});
export { router as actionItemsRouter };

View File

@ -0,0 +1,32 @@
import { z } from 'zod';
export const createActionItemSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional(),
agendaId: z.string().optional(),
assigneeUserId: z.string().optional(),
dueDate: z.string().datetime().optional(),
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
});
export const updateActionItemSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
assigneeUserId: z.string().nullable().optional(),
dueDate: z.string().datetime().nullable().optional(),
status: z.enum(['open', 'in_progress', 'done', 'blocked']).optional(),
priority: z.enum(['low', 'normal', 'high', 'urgent']).optional(),
});
export const listActionItemsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.enum(['open', 'in_progress', 'done', 'blocked']).optional(),
assigneeUserId: z.string().optional(),
overdue: z.preprocess((val) => val === 'true' || val === true, z.boolean().optional()),
});
export type CreateActionItemInput = z.infer<typeof createActionItemSchema>;
export type UpdateActionItemInput = z.infer<typeof updateActionItemSchema>;
export type ListActionItemsInput = z.infer<typeof listActionItemsSchema>;

View File

@ -0,0 +1,140 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import type {
CreateActionItemInput,
UpdateActionItemInput,
ListActionItemsInput,
} from './action-items.schemas';
const actionItemInclude = {
assignee: { select: { id: true, name: true, email: true } },
createdBy: { select: { id: true, name: true } },
agenda: { select: { id: true, title: true } },
} as const;
export const actionItemsService = {
async findAll(filters: ListActionItemsInput) {
const { page, limit, search, status, assigneeUserId, overdue } = filters;
const where: Prisma.ActionItemWhereInput = {};
if (status) where.status = status;
if (assigneeUserId) where.assigneeUserId = assigneeUserId;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
if (overdue) {
where.dueDate = { lt: new Date() };
where.status = { not: 'done' };
}
const [items, total] = await Promise.all([
prisma.actionItem.findMany({
where,
include: actionItemInclude,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.actionItem.count({ where }),
]);
return {
actionItems: items,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
},
async findById(id: string) {
const item = await prisma.actionItem.findUnique({
where: { id },
include: actionItemInclude,
});
if (!item) throw new AppError(404, 'Action item not found');
return item;
},
async findByUser(userId: string, status?: string) {
const where: Prisma.ActionItemWhereInput = {
assigneeUserId: userId,
status: status ? status : { not: 'done' },
};
const items = await prisma.actionItem.findMany({
where,
include: actionItemInclude,
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
});
return {
actionItems: items,
pagination: { page: 1, limit: items.length, total: items.length, totalPages: 1 },
};
},
async create(data: CreateActionItemInput, userId: string) {
return prisma.actionItem.create({
data: {
title: data.title,
description: data.description,
agendaId: data.agendaId,
assigneeUserId: data.assigneeUserId,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
priority: data.priority,
createdByUserId: userId,
},
include: actionItemInclude,
});
},
async update(id: string, data: UpdateActionItemInput) {
const existing = await prisma.actionItem.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Action item not found');
const updateData: Prisma.ActionItemUncheckedUpdateInput = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description;
if (data.assigneeUserId !== undefined) updateData.assigneeUserId = data.assigneeUserId;
if (data.dueDate !== undefined) updateData.dueDate = data.dueDate ? new Date(data.dueDate) : null;
if (data.priority !== undefined) updateData.priority = data.priority;
if (data.status !== undefined) {
updateData.status = data.status;
if (data.status === 'done' && existing.status !== 'done') {
updateData.completedAt = new Date();
} else if (data.status !== 'done' && existing.status === 'done') {
updateData.completedAt = null;
}
}
return prisma.actionItem.update({
where: { id },
data: updateData,
include: actionItemInclude,
});
},
async delete(id: string) {
const existing = await prisma.actionItem.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Action item not found');
await prisma.actionItem.delete({ where: { id } });
},
async getOverdue() {
return prisma.actionItem.findMany({
where: {
dueDate: { lt: new Date() },
status: { not: 'done' },
},
include: actionItemInclude,
orderBy: { dueDate: 'asc' },
});
},
};

View File

@ -0,0 +1,98 @@
import { Router, Request, Response, NextFunction } from 'express';
import { agendaService } from './agenda.service';
import {
createAgendaSchema,
updateAgendaSchema,
createMinutesSchema,
updateMinutesSchema,
listAgendasSchema,
} from './agenda.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { EVENTS_ROLES } from '../../utils/roles';
const router = Router();
router.use(authenticate);
router.use(requireRole(...EVENTS_ROLES));
// List agendas
router.get('/', validate(listAgendasSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await agendaService.findAll(req.query as any);
res.json(result);
} catch (err) { next(err); }
});
// Get agenda detail
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const agenda = await agendaService.findById(id);
res.json(agenda);
} catch (err) { next(err); }
});
// Create agenda
router.post('/', validate(createAgendaSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const agenda = await agendaService.create(req.body, req.user!.id);
res.status(201).json(agenda);
} catch (err) { next(err); }
});
// Update agenda
router.put('/:id', validate(updateAgendaSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const agenda = await agendaService.update(id, req.body);
res.json(agenda);
} catch (err) { next(err); }
});
// Delete agenda
router.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await agendaService.delete(id);
res.json({ message: 'Agenda deleted' });
} catch (err) { next(err); }
});
// Create minutes for agenda
router.post('/:id/minutes', validate(createMinutesSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
req.body.agendaId = id;
const minutes = await agendaService.createMinutes(req.body, req.user!.id);
res.status(201).json(minutes);
} catch (err) { next(err); }
});
// Update minutes for agenda
router.put('/:id/minutes', validate(updateMinutesSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const agenda = await agendaService.findById(id);
if (!agenda.minutes) {
return res.status(404).json({ message: 'Minutes not found for this agenda' });
}
const minutes = await agendaService.updateMinutes(agenda.minutes.id, req.body);
res.json(minutes);
} catch (err) { next(err); }
});
// Approve minutes
router.post('/:id/minutes/approve', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const agenda = await agendaService.findById(id);
if (!agenda.minutes) {
return res.status(404).json({ message: 'Minutes not found for this agenda' });
}
const minutes = await agendaService.approveMinutes(agenda.minutes.id, req.user!.id);
res.json(minutes);
} catch (err) { next(err); }
});
export { router as agendaRouter };

View File

@ -0,0 +1,40 @@
import { z } from 'zod';
export const createAgendaSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
shiftId: z.string().optional(),
pollId: z.string().optional(),
items: z.array(z.any()).optional().default([]),
});
export const updateAgendaSchema = z.object({
title: z.string().min(1).max(200).optional(),
items: z.array(z.any()).optional(),
status: z.enum(['draft', 'active', 'completed']).optional(),
});
export const createMinutesSchema = z.object({
agendaId: z.string().min(1, 'Agenda ID is required'),
notes: z.string().min(1, 'Notes are required'),
decisions: z.array(z.any()).optional().default([]),
attendees: z.array(z.any()).optional().default([]),
});
export const updateMinutesSchema = z.object({
notes: z.string().min(1).optional(),
decisions: z.array(z.any()).optional(),
attendees: z.array(z.any()).optional(),
});
export const listAgendasSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.enum(['draft', 'active', 'completed']).optional(),
});
export type CreateAgendaInput = z.infer<typeof createAgendaSchema>;
export type UpdateAgendaInput = z.infer<typeof updateAgendaSchema>;
export type CreateMinutesInput = z.infer<typeof createMinutesSchema>;
export type UpdateMinutesInput = z.infer<typeof updateMinutesSchema>;
export type ListAgendasInput = z.infer<typeof listAgendasSchema>;

View File

@ -0,0 +1,171 @@
import { Prisma } from '@prisma/client';
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import type {
CreateAgendaInput,
UpdateAgendaInput,
CreateMinutesInput,
UpdateMinutesInput,
ListAgendasInput,
} from './agenda.schemas';
export const agendaService = {
async findAll(filters: ListAgendasInput) {
const { page, limit, search, status } = filters;
const where: Prisma.MeetingAgendaWhereInput = {};
if (status) where.status = status;
if (search) {
where.title = { contains: search, mode: 'insensitive' };
}
const [agendas, total] = await Promise.all([
prisma.meetingAgenda.findMany({
where,
include: {
createdBy: { select: { id: true, name: true, email: true } },
shift: { select: { id: true, title: true, date: true } },
poll: { select: { id: true, title: true } },
_count: { select: { actionItems: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.meetingAgenda.count({ where }),
]);
return {
agendas,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
},
};
},
async findById(id: string) {
const agenda = await prisma.meetingAgenda.findUnique({
where: { id },
include: {
createdBy: { select: { id: true, name: true, email: true } },
shift: { select: { id: true, title: true, date: true } },
poll: { select: { id: true, title: true } },
minutes: {
include: {
createdBy: { select: { id: true, name: true } },
approvedBy: { select: { id: true, name: true } },
},
},
actionItems: {
include: {
assignee: { select: { id: true, name: true, email: true } },
createdBy: { select: { id: true, name: true } },
},
orderBy: { createdAt: 'asc' },
},
},
});
if (!agenda) throw new AppError(404, 'Agenda not found');
return agenda;
},
async create(data: CreateAgendaInput, userId: string) {
return prisma.meetingAgenda.create({
data: {
title: data.title,
shiftId: data.shiftId,
pollId: data.pollId,
items: data.items as unknown as Prisma.InputJsonValue,
createdByUserId: userId,
},
include: {
createdBy: { select: { id: true, name: true, email: true } },
},
});
},
async update(id: string, data: UpdateAgendaInput) {
const existing = await prisma.meetingAgenda.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Agenda not found');
const updateData: Prisma.MeetingAgendaUncheckedUpdateInput = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.items !== undefined) updateData.items = data.items as unknown as Prisma.InputJsonValue;
if (data.status !== undefined) updateData.status = data.status;
return prisma.meetingAgenda.update({
where: { id },
data: updateData,
include: {
createdBy: { select: { id: true, name: true, email: true } },
},
});
},
async delete(id: string) {
const existing = await prisma.meetingAgenda.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Agenda not found');
await prisma.meetingAgenda.delete({ where: { id } });
},
async createMinutes(data: CreateMinutesInput, userId: string) {
const agenda = await prisma.meetingAgenda.findUnique({ where: { id: data.agendaId } });
if (!agenda) throw new AppError(404, 'Agenda not found');
const existing = await prisma.meetingMinutes.findUnique({ where: { agendaId: data.agendaId } });
if (existing) throw new AppError(400, 'Minutes already exist for this agenda');
return prisma.meetingMinutes.create({
data: {
agendaId: data.agendaId,
notes: data.notes,
decisions: data.decisions as unknown as Prisma.InputJsonValue,
attendees: data.attendees as unknown as Prisma.InputJsonValue,
createdByUserId: userId,
},
include: {
createdBy: { select: { id: true, name: true } },
},
});
},
async updateMinutes(id: string, data: UpdateMinutesInput) {
const existing = await prisma.meetingMinutes.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Minutes not found');
const updateData: Prisma.MeetingMinutesUncheckedUpdateInput = {};
if (data.notes !== undefined) updateData.notes = data.notes;
if (data.decisions !== undefined) updateData.decisions = data.decisions as unknown as Prisma.InputJsonValue;
if (data.attendees !== undefined) updateData.attendees = data.attendees as unknown as Prisma.InputJsonValue;
return prisma.meetingMinutes.update({
where: { id },
data: updateData,
include: {
createdBy: { select: { id: true, name: true } },
approvedBy: { select: { id: true, name: true } },
},
});
},
async approveMinutes(id: string, userId: string) {
const existing = await prisma.meetingMinutes.findUnique({ where: { id } });
if (!existing) throw new AppError(404, 'Minutes not found');
if (existing.approvedAt) throw new AppError(400, 'Minutes are already approved');
return prisma.meetingMinutes.update({
where: { id },
data: {
approvedAt: new Date(),
approvedByUserId: userId,
},
include: {
createdBy: { select: { id: true, name: true } },
approvedBy: { select: { id: true, name: true } },
},
});
},
};

View File

@ -0,0 +1,107 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { validate } from '../../middleware/validate';
import { EVENTS_ROLES } from '../../utils/roles';
import { participantNeedsService } from './participant-needs.service';
import { upsertNeedsSchema } from './participant-needs.schemas';
const router = Router();
// GET /api/people/needs/me
router.get(
'/me',
authenticate,
async (req: Request, res: Response, next: NextFunction) => {
try {
const needs = await participantNeedsService.findByUserId(req.user!.id);
res.json({ needs });
} catch (err) {
next(err);
}
}
);
// PUT /api/people/needs/me
router.put(
'/me',
authenticate,
validate(upsertNeedsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const needs = await participantNeedsService.upsert(req.body, req.user!.id);
res.json({ needs });
} catch (err) {
next(err);
}
}
);
// GET /api/people/needs/aggregate
router.get(
'/aggregate',
authenticate,
requireRole(...EVENTS_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userIdsParam = req.query.userIds as string | undefined;
const contactIdsParam = req.query.contactIds as string | undefined;
const userIds = userIdsParam ? userIdsParam.split(',').filter(Boolean) : [];
const contactIds = contactIdsParam ? contactIdsParam.split(',').filter(Boolean) : [];
const summary = await participantNeedsService.aggregateForVoters(userIds, contactIds);
res.json({ summary });
} catch (err) {
next(err);
}
}
);
// GET /api/people/needs/user/:userId
router.get(
'/user/:userId',
authenticate,
requireRole(...EVENTS_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const userId = req.params.userId as string;
const needs = await participantNeedsService.findByUserId(userId);
res.json({ needs });
} catch (err) {
next(err);
}
}
);
// GET /api/people/needs/contact/:contactId
router.get(
'/contact/:contactId',
authenticate,
requireRole(...EVENTS_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const contactId = req.params.contactId as string;
const needs = await participantNeedsService.findByContactId(contactId);
res.json({ needs });
} catch (err) {
next(err);
}
}
);
// DELETE /api/people/needs/:id
router.delete(
'/:id',
authenticate,
requireRole(...EVENTS_ROLES),
async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
await participantNeedsService.deleteById(id);
res.status(204).send();
} catch (err) {
next(err);
}
}
);
export { router as participantNeedsRouter };

View File

@ -0,0 +1,35 @@
import { z } from 'zod';
export const upsertNeedsSchema = z.object({
needsWheelchair: z.boolean().optional(),
needsGroundFloor: z.boolean().optional(),
needsHearingLoop: z.boolean().optional(),
needsSignLanguage: z.boolean().optional(),
otherAccessibility: z.string().max(1000).nullable().optional(),
isVegan: z.boolean().optional(),
isVegetarian: z.boolean().optional(),
isGlutenFree: z.boolean().optional(),
isHalal: z.boolean().optional(),
isKosher: z.boolean().optional(),
hasNutAllergy: z.boolean().optional(),
otherDietary: z.string().max(1000).nullable().optional(),
needsChildcare: z.boolean().optional(),
childcareDetails: z.string().max(1000).nullable().optional(),
needsTransportation: z.boolean().optional(),
transportationNotes: z.string().max(1000).nullable().optional(),
preferredLanguage: z.string().max(10).nullable().optional(),
needsTranslation: z.boolean().optional(),
translationLanguage: z.string().max(100).nullable().optional(),
visibilityConsent: z.enum(['organizer_only', 'shared_with_hosts', 'public']).default('organizer_only'),
});
export const getNeedsSchema = z.object({
id: z.string().min(1),
});
export type UpsertNeedsInput = z.infer<typeof upsertNeedsSchema>;
export type GetNeedsInput = z.infer<typeof getNeedsSchema>;

View File

@ -0,0 +1,129 @@
import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler';
import { logger } from '../../utils/logger';
import type { UpsertNeedsInput } from './participant-needs.schemas';
export const participantNeedsService = {
async upsert(data: UpsertNeedsInput, userId?: string, contactId?: string) {
if (!userId && !contactId) {
throw new AppError(400, 'Either userId or contactId is required', 'MISSING_IDENTIFIER');
}
const where = userId
? { userId }
: { contactId: contactId! };
const existing = await prisma.participantNeeds.findUnique({ where });
if (existing) {
return prisma.participantNeeds.update({
where: { id: existing.id },
data,
});
}
return prisma.participantNeeds.create({
data: {
...data,
...(userId ? { userId } : { contactId }),
},
});
},
async findByUserId(userId: string) {
return prisma.participantNeeds.findUnique({ where: { userId } });
},
async findByContactId(contactId: string) {
return prisma.participantNeeds.findUnique({ where: { contactId } });
},
async aggregateForVoters(userIds: string[], contactIds: string[]) {
const records = await prisma.participantNeeds.findMany({
where: {
OR: [
...(userIds.length > 0 ? [{ userId: { in: userIds } }] : []),
...(contactIds.length > 0 ? [{ contactId: { in: contactIds } }] : []),
],
},
});
const summary = {
total: records.length,
accessibility: {
wheelchair: 0,
groundFloor: 0,
hearingLoop: 0,
signLanguage: 0,
other: 0,
},
dietary: {
vegan: 0,
vegetarian: 0,
glutenFree: 0,
halal: 0,
kosher: 0,
nutAllergy: 0,
other: 0,
},
childcare: 0,
transportation: 0,
translation: 0,
languages: {} as Record<string, number>,
};
for (const r of records) {
if (r.needsWheelchair) summary.accessibility.wheelchair++;
if (r.needsGroundFloor) summary.accessibility.groundFloor++;
if (r.needsHearingLoop) summary.accessibility.hearingLoop++;
if (r.needsSignLanguage) summary.accessibility.signLanguage++;
if (r.otherAccessibility) summary.accessibility.other++;
if (r.isVegan) summary.dietary.vegan++;
if (r.isVegetarian) summary.dietary.vegetarian++;
if (r.isGlutenFree) summary.dietary.glutenFree++;
if (r.isHalal) summary.dietary.halal++;
if (r.isKosher) summary.dietary.kosher++;
if (r.hasNutAllergy) summary.dietary.nutAllergy++;
if (r.otherDietary) summary.dietary.other++;
if (r.needsChildcare) summary.childcare++;
if (r.needsTransportation) summary.transportation++;
if (r.needsTranslation) summary.translation++;
if (r.translationLanguage) {
summary.languages[r.translationLanguage] = (summary.languages[r.translationLanguage] || 0) + 1;
}
}
return summary;
},
async deleteById(id: string) {
const existing = await prisma.participantNeeds.findUnique({ where: { id } });
if (!existing) {
throw new AppError(404, 'Participant needs record not found', 'NOT_FOUND');
}
await prisma.participantNeeds.delete({ where: { id } });
},
async purgeExpired() {
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 7);
const result = await prisma.participantNeeds.deleteMany({
where: {
updatedAt: { lt: cutoff },
userId: null,
contactId: null,
},
});
if (result.count > 0) {
logger.info(`Purged ${result.count} orphaned participant needs records older than 7 days`);
}
return { purged: result.count };
},
};

View File

@ -10,6 +10,7 @@ export const createUserSchema = z.object({
.regex(/[0-9]/, 'Password must contain at least one digit'),
name: z.string().optional(),
phone: z.string().optional(),
pronouns: z.string().max(50).optional(),
role: z.nativeEnum(UserRole).optional(),
roles: z.array(z.nativeEnum(UserRole)).optional(),
status: z.nativeEnum(UserStatus).optional(),
@ -27,6 +28,7 @@ export const updateUserSchema = z.object({
.optional(),
name: z.string().optional(),
phone: z.string().optional(),
pronouns: z.string().max(50).nullable().optional(),
role: z.nativeEnum(UserRole).optional(),
roles: z.array(z.nativeEnum(UserRole)).optional(),
status: z.nativeEnum(UserStatus).optional(),

View File

@ -13,6 +13,7 @@ const userSelect = {
email: true,
name: true,
phone: true,
pronouns: true,
role: true,
roles: true,
status: true,

View File

@ -93,6 +93,7 @@ import { socialDigestService } from './services/social-digest.service';
import { termuxClient } from './services/termux.client';
import { registerProvisioners } from './services/user-provisioning';
import { peopleRouter } from './modules/people/people.routes';
import { participantNeedsRouter } from './modules/people/participant-needs.routes';
import { profilePublicRouter } from './modules/people/profile-public.routes';
import { searchRouter } from './modules/search/search.routes';
import { activityPublicRouter } from './modules/activity/activity-public.routes';
@ -115,6 +116,9 @@ import { upgradeService } from './modules/upgrade/upgrade.service';
import { autoUpgradeService } from './services/auto-upgrade.service';
import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service';
import { pollAutoFinalizeQueueService } from './services/poll-auto-finalize-queue.service';
import { agendaRouter } from './modules/meetings/agenda.routes';
import { actionItemsRouter } from './modules/meetings/action-items.routes';
import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service';
@ -227,6 +231,8 @@ app.use('/api/map/settings', mapSettingsRouter); // Map settings (public
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
app.use('/api/meetings/agendas', agendaRouter); // Meeting agendas + minutes (EVENTS roles)
app.use('/api/meetings/action-items', actionItemsRouter); // Action items CRUD (EVENTS roles / auth)
app.use('/api/qr', qrRouter); // QR code generation (public)
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
@ -268,6 +274,7 @@ app.use('/api/sms/device', smsDeviceRouter); // SMS device sta
app.use('/api/sms/templates', smsTemplatesRouter); // SMS template CRUD (ADMIN roles)
app.use('/api/sms/setup', smsSetupRouter); // SMS setup wizard (SUPER_ADMIN only)
app.use('/api/profile', profilePublicRouter); // Self-service contact profile (no auth, token-based)
app.use('/api/people/needs', participantNeedsRouter); // Participant needs (self-service + EVENTS roles)
app.use('/api/people', peopleRouter); // People CRM aggregation (ADMIN roles)
app.use('/api/search', searchRouter); // Public unified search (no auth, rate-limited)
app.use('/api/activity', activityPublicRouter); // Public activity feed (no auth)
@ -325,6 +332,7 @@ async function start() {
geocodeQueueService.startWorker();
calendarFeedQueueService.startWorker();
scheduledJobsQueueService.startWorker();
pollAutoFinalizeQueueService.startWorker();
startProxy();
// Load SMS config from DB (env fallback for empty fields)

View File

@ -0,0 +1,132 @@
import { Queue, Worker, type Job } from 'bullmq';
import { env } from '../config/env';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
interface PollAutoFinalizeJobData {
pollId: string;
}
class PollAutoFinalizeQueueService {
private queue: Queue;
private worker: Worker | null = null;
constructor() {
this.queue = new Queue('poll-auto-finalize', {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 500 },
removeOnFail: { age: 30 * 24 * 60 * 60 },
},
});
}
startWorker() {
this.worker = new Worker(
'poll-auto-finalize',
async (job: Job<PollAutoFinalizeJobData>) => {
const { pollId } = job.data;
logger.info(`Processing poll auto-finalize job ${job.id}`, { pollId });
// Dynamic import to avoid circular dependency
const { meetingPlannerService } = await import(
'../modules/meeting-planner/meeting-planner.service'
);
await meetingPlannerService.processAutoFinalize(pollId);
},
{
connection: { url: env.REDIS_URL },
concurrency: 1,
}
);
this.worker.on('completed', (job) => {
logger.info(`Poll auto-finalize job ${job.id} completed`);
});
this.worker.on('failed', (job, err) => {
logger.error(`Poll auto-finalize job ${job?.id} failed: ${err.message}`);
});
logger.info('Poll auto-finalize queue worker started');
// Startup recovery: process past-due polls and re-schedule future ones
this.recoverOnStartup().catch((err) =>
logger.error('Poll auto-finalize startup recovery failed', { error: err })
);
}
async scheduleJob(pollId: string, deadline: Date): Promise<string | null> {
const delay = deadline.getTime() - Date.now();
if (delay <= 0) {
// Already past deadline — process immediately
const job = await this.queue.add(`finalize-${pollId}`, { pollId }, {
jobId: `poll-finalize-${pollId}`,
});
return job.id ?? null;
}
const job = await this.queue.add(`finalize-${pollId}`, { pollId }, {
delay,
jobId: `poll-finalize-${pollId}`,
});
logger.info(`Scheduled poll auto-finalize for ${deadline.toISOString()}`, {
pollId,
jobId: job.id,
delayMs: delay,
});
return job.id ?? null;
}
async cancelJob(pollId: string): Promise<void> {
try {
const jobs = await this.queue.getJobs(['delayed', 'waiting']);
for (const job of jobs) {
if (job.data.pollId === pollId) {
await job.remove();
logger.info(`Cancelled auto-finalize job for poll ${pollId}`, { jobId: job.id });
}
}
} catch (error) {
logger.error('Failed to cancel poll auto-finalize job', { error, pollId });
}
}
private async recoverOnStartup() {
const openPolls = await prisma.schedulingPoll.findMany({
where: {
autoFinalize: true,
status: 'OPEN',
votingDeadline: { not: null },
},
select: { id: true, votingDeadline: true },
});
for (const poll of openPolls) {
if (!poll.votingDeadline) continue;
const jobId = await this.scheduleJob(poll.id, poll.votingDeadline);
if (jobId) {
await prisma.schedulingPoll.update({
where: { id: poll.id },
data: { autoFinalizeJobId: jobId },
}).catch(() => {}); // Best-effort
}
}
if (openPolls.length > 0) {
logger.info(`Recovered ${openPolls.length} poll auto-finalize jobs on startup`);
}
}
async close() {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
logger.info('Poll auto-finalize queue closed');
}
}
export const pollAutoFinalizeQueueService = new PollAutoFinalizeQueueService();

View File

@ -14,7 +14,8 @@ type ScheduledJobType =
| 'cleanup-verification-tokens'
| 'listmonk-full-sync'
| 'validate-mkdocs-exports'
| 'cleanup-docs-collab-states';
| 'cleanup-docs-collab-states'
| 'purge-expired-participant-needs';
interface ScheduledJobData {
type: ScheduledJobType;
@ -33,6 +34,7 @@ const JOB_DEFINITIONS: Array<{ type: ScheduledJobType; every: number; conditiona
{ type: 'listmonk-full-sync', every: 6 * HOUR, conditional: true },
{ type: 'validate-mkdocs-exports', every: 24 * HOUR },
{ type: 'cleanup-docs-collab-states', every: 24 * HOUR },
{ type: 'purge-expired-participant-needs', every: 24 * HOUR },
];
async function executeJob(type: ScheduledJobType): Promise<void> {
@ -89,6 +91,11 @@ async function executeJob(type: ScheduledJobType): Promise<void> {
await docsCollabService.cleanupStaleStates();
break;
}
case 'purge-expired-participant-needs': {
const { participantNeedsService } = await import('../modules/people/participant-needs.service');
await participantNeedsService.purgeExpired();
break;
}
}
}
@ -145,7 +152,7 @@ class ScheduledJobsQueueService {
logger.error(`Scheduled job ${job?.name} failed: ${err.message}`);
});
logger.info('Scheduled jobs queue worker started (10 job types)');
logger.info('Scheduled jobs queue worker started (11 job types)');
}
async close() {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -143,7 +143,7 @@
"assets/images/social/services/postgresql.png": "831fb68dd3e01d9a017e59b100aaa8a455c8c112",
"assets/images/social/services/static-server.png": "f36d527c80adba4bcb7778784683f429acb4ce74",
"assets/images/social/test-2.png": "a6ae43d52d7c58fc106a562777e03b7da2263f83",
"assets/images/social/test-page.png": "c9d5751a1f0a4c1341336bb7d00c9bc743d33ef4",
"assets/images/social/test-page.png": "af2c353ee377343678a58555726f6ad7d0624488",
"assets/images/social/test.png": "a6ae43d52d7c58fc106a562777e03b7da2263f83",
"assets/images/social/testing.png": "f7aaf394b71cbe7084a6afa0e75a324ca59e23d8",
"assets/images/social/v1/adv/ansible.png": "cb542ad9a3cc9a869258b3b1353966e1b9616a2b",

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 993 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

View File

@ -7,10 +7,10 @@
"stars_count": 0,
"forks_count": 0,
"open_issues_count": 23,
"updated_at": "2026-03-09T12:23:17-06:00",
"updated_at": "2026-03-10T18:27:08-06:00",
"created_at": "2025-05-28T14:54:59-06:00",
"clone_url": "https://gitea.bnkops.com/admin/changemaker.lite.git",
"ssh_url": "git@gitea.bnkops.com:admin/changemaker.lite.git",
"default_branch": "main",
"last_build_update": "2026-03-09T12:23:17-06:00"
"last_build_update": "2026-03-10T18:27:08-06:00"
}

View File

@ -4,13 +4,13 @@
"description": "Claude Code is an agentic coding tool that lives in your terminal, understands your codebase, and helps you code faster by executing routine tasks, explaining complex code, and handling git workflows - all through natural language commands.",
"html_url": "https://github.com/anthropics/claude-code",
"language": "Shell",
"stars_count": 75798,
"forks_count": 6114,
"open_issues_count": 5868,
"updated_at": "2026-03-09T21:59:41Z",
"stars_count": 77129,
"forks_count": 6266,
"open_issues_count": 6171,
"updated_at": "2026-03-12T17:36:34Z",
"created_at": "2025-02-22T17:41:21Z",
"clone_url": "https://github.com/anthropics/claude-code.git",
"ssh_url": "git@github.com:anthropics/claude-code.git",
"default_branch": "main",
"last_build_update": "2026-03-07T00:12:45Z"
"last_build_update": "2026-03-12T07:12:41Z"
}

View File

@ -4,13 +4,13 @@
"description": "VS Code in the browser",
"html_url": "https://github.com/coder/code-server",
"language": "TypeScript",
"stars_count": 76554,
"forks_count": 6541,
"open_issues_count": 169,
"updated_at": "2026-03-09T20:21:15Z",
"stars_count": 76609,
"forks_count": 6547,
"open_issues_count": 172,
"updated_at": "2026-03-12T17:13:08Z",
"created_at": "2019-02-27T16:50:41Z",
"clone_url": "https://github.com/coder/code-server.git",
"ssh_url": "git@github.com:coder/code-server.git",
"default_branch": "main",
"last_build_update": "2026-03-09T19:30:51Z"
"last_build_update": "2026-03-11T22:19:15Z"
}

View File

@ -4,13 +4,13 @@
"description": "A highly customizable homepage (or startpage / application dashboard) with Docker and service API integrations.",
"html_url": "https://github.com/gethomepage/homepage",
"language": "JavaScript",
"stars_count": 28818,
"forks_count": 1812,
"stars_count": 28865,
"forks_count": 1813,
"open_issues_count": 1,
"updated_at": "2026-03-09T21:38:58Z",
"updated_at": "2026-03-12T15:01:15Z",
"created_at": "2022-08-24T07:29:42Z",
"clone_url": "https://github.com/gethomepage/homepage.git",
"ssh_url": "git@github.com:gethomepage/homepage.git",
"default_branch": "dev",
"last_build_update": "2026-03-09T17:02:06Z"
"last_build_update": "2026-03-12T12:22:19Z"
}

View File

@ -4,13 +4,13 @@
"description": "Git with a cup of tea! Painless self-hosted all-in-one software development service, including Git hosting, code review, team collaboration, package registry and CI/CD",
"html_url": "https://github.com/go-gitea/gitea",
"language": "Go",
"stars_count": 54194,
"forks_count": 6448,
"open_issues_count": 2850,
"updated_at": "2026-03-09T21:55:30Z",
"stars_count": 54251,
"forks_count": 6466,
"open_issues_count": 2853,
"updated_at": "2026-03-12T17:33:45Z",
"created_at": "2016-11-01T02:13:26Z",
"clone_url": "https://github.com/go-gitea/gitea.git",
"ssh_url": "git@github.com:go-gitea/gitea.git",
"default_branch": "main",
"last_build_update": "2026-03-09T10:12:24Z"
"last_build_update": "2026-03-12T08:26:47Z"
}

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