Bunch of updates to scheduling
4
.gitignore
vendored
@ -47,6 +47,10 @@ docker-compose.override.yml
|
||||
# Build output
|
||||
/admin/dist/
|
||||
|
||||
# Core dumps
|
||||
core.*
|
||||
*/core.*
|
||||
|
||||
# MkDocs core binary
|
||||
/mkdocs/core
|
||||
|
||||
|
||||
2
.playwright-mcp/console-2026-03-11T04-08-16-370Z.log
Normal 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
|
||||
6
.playwright-mcp/console-2026-03-11T04-09-16-650Z.log
Normal 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
|
||||
26
.playwright-mcp/console-2026-03-11T04-18-58-612Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T04-30-19-709Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-11T04-32-23-770Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T04-32-35-003Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T04-59-43-597Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T05-12-37-187Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T05-20-51-100Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T05-23-22-971Z.log
Normal 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
|
||||
6
.playwright-mcp/console-2026-03-11T19-11-45-992Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T21-01-17-074Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T21-02-52-102Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T21-37-02-291Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T21-38-54-104Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-11T21-41-12-923Z.log
Normal 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
|
||||
14
.playwright-mcp/console-2026-03-11T21-45-32-431Z.log
Normal 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
|
||||
21
.playwright-mcp/console-2026-03-12T04-52-24-612Z.log
Normal 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
|
||||
8
.playwright-mcp/console-2026-03-12T04-53-50-505Z.log
Normal 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
|
||||
30
.playwright-mcp/console-2026-03-12T05-00-21-925Z.log
Normal 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
|
||||
2
.playwright-mcp/console-2026-03-12T13-15-19-805Z.log
Normal 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
|
||||
44
.playwright-mcp/console-2026-03-12T13-20-20-354Z.log
Normal 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
|
||||
1
.playwright-mcp/console-2026-03-13T00-33-00-576Z.log
Normal 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
|
||||
194
.playwright-mcp/console-2026-03-13T00-35-31-307Z.log
Normal 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
|
||||
BIN
.playwright-mcp/page-2026-03-11T05-31-06-498Z.png
Normal file
|
After Width: | Height: | Size: 130 KiB |
@ -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={
|
||||
|
||||
@ -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' });
|
||||
|
||||
128
admin/src/components/dashboard/MyActionItemsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
441
admin/src/pages/ActionItemsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 />
|
||||
|
||||
817
admin/src/pages/MeetingAgendaPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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})`}>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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 & 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 />}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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({
|
||||
|
||||
77
api/src/modules/meetings/action-items.routes.ts
Normal 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 };
|
||||
32
api/src/modules/meetings/action-items.schemas.ts
Normal 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>;
|
||||
140
api/src/modules/meetings/action-items.service.ts
Normal 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' },
|
||||
});
|
||||
},
|
||||
};
|
||||
98
api/src/modules/meetings/agenda.routes.ts
Normal 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 };
|
||||
40
api/src/modules/meetings/agenda.schemas.ts
Normal 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>;
|
||||
171
api/src/modules/meetings/agenda.service.ts
Normal 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 } },
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
107
api/src/modules/people/participant-needs.routes.ts
Normal 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 };
|
||||
35
api/src/modules/people/participant-needs.schemas.ts
Normal 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>;
|
||||
129
api/src/modules/people/participant-needs.service.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
@ -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(),
|
||||
|
||||
@ -13,6 +13,7 @@ const userSelect = {
|
||||
email: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
pronouns: true,
|
||||
role: true,
|
||||
roles: true,
|
||||
status: true,
|
||||
|
||||
@ -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)
|
||||
|
||||
132
api/src/services/poll-auto-finalize-queue.service.ts
Normal 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();
|
||||
@ -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() {
|
||||
|
||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 65 KiB |
@ -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",
|
||||
|
||||
BIN
mkdocs/docs/assets/images/screenshots/admin/areas.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/campaigns.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/canvassing.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/dashboard.png
Normal file
|
After Width: | Height: | Size: 435 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/data-quality.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/effectiveness.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/email-queue.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/email-templates.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/landing-pages.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/locations.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/map-settings.png
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/media-analytics.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/media-curated.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/media-library.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/monitoring.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/newsletter.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/representatives.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/responses.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/settings.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/shifts.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/sms.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/tunnel.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
mkdocs/docs/assets/images/screenshots/admin/users.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 136 KiB |
BIN
mkdocs/docs/assets/images/screenshots/getting-started/login.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
|
After Width: | Height: | Size: 82 KiB |
BIN
mkdocs/docs/assets/images/screenshots/getting-started/shifts.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
mkdocs/docs/assets/images/screenshots/getting-started/users.png
Normal file
|
After Width: | Height: | Size: 92 KiB |
BIN
mkdocs/docs/assets/images/screenshots/public/campaigns.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
mkdocs/docs/assets/images/screenshots/public/gallery.png
Normal file
|
After Width: | Height: | Size: 253 KiB |
BIN
mkdocs/docs/assets/images/screenshots/public/home.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
BIN
mkdocs/docs/assets/images/screenshots/public/map.png
Normal file
|
After Width: | Height: | Size: 993 KiB |
BIN
mkdocs/docs/assets/images/screenshots/public/pricing.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
mkdocs/docs/assets/images/screenshots/public/shifts.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
mkdocs/docs/assets/images/screenshots/public/shop.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
mkdocs/docs/assets/images/screenshots/public/wall-of-fame.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||